diff --git a/packages/app/src/cli/models/app/app.test-data.ts b/packages/app/src/cli/models/app/app.test-data.ts index f9933958bfc..d01281b9f49 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -219,8 +219,30 @@ export async function testUIExtension( }, }, extension_points: [ - {target: 'target1', module: 'module1'}, - {target: 'target2', module: 'module2'}, + { + target: 'target1', + module: 'module1', + build_manifest: { + assets: { + main: { + module: 'module1', + filepath: uiExtension?.handle ? `/${uiExtension.handle}.js` : '/test-ui-extension.js', + }, + }, + }, + }, + { + target: 'target2', + module: 'module2', + build_manifest: { + assets: { + main: { + module: 'module2', + filepath: uiExtension?.handle ? `/${uiExtension.handle}.js` : '/test-ui-extension.js', + }, + }, + }, + }, ], } const configurationPath = uiExtension?.configurationPath ?? `${directory}/shopify.ui.extension.toml` diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 6c67924cca2..72b7915ac37 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -255,7 +255,7 @@ export class ExtensionInstance string + getBundleExtensionStdinContent?: (config: TConfiguration) => {main: string; assets?: Asset[]} deployConfig?: ( config: TConfiguration, directory: string, diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts index bd489a84ac3..2a7c88dced4 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts @@ -11,7 +11,27 @@ import {describe, expect, test, vi} from 'vitest' describe('ui_extension', async () => { interface GetUIExtensionProps { directory: string - extensionPoints?: {target: string; module: string; label?: string; default_placement_reference?: string}[] + extensionPoints?: { + target: string + module: string + label?: string + default_placement_reference?: string + should_render?: { + module: string + } + build_manifest?: { + assets: { + main: { + filepath: string + module: string + } + should_render?: { + filepath: string + module: string + } + } + } + }[] } async function getTestUIExtension({directory, extensionPoints}: GetUIExtensionProps) { @@ -82,9 +102,13 @@ describe('ui_extension', async () => { { target: 'EXTENSION::POINT::A', module: './src/ExtensionPointA.js', + should_render: { + module: './src/ShouldRender.js', + }, }, ], api_version: '2023-01' as const, + handle: 'test-ui-extension', name: 'UI Extension', description: 'This is an ordinary test extension', type: 'ui_extension', @@ -121,6 +145,18 @@ describe('ui_extension', async () => { default_placement_reference: undefined, capabilities: undefined, preloads: {}, + build_manifest: { + assets: { + main: { + filepath: 'test-ui-extension.js', + module: './src/ExtensionPointA.js', + }, + should_render: { + filepath: 'test-ui-extension-conditions.js', + module: './src/ShouldRender.js', + }, + }, + }, }, ]) }) @@ -140,6 +176,7 @@ describe('ui_extension', async () => { name: 'UI Extension', description: 'This is an ordinary test extension', type: 'ui_extension', + handle: 'test-ui-extension', capabilities: { block_progress: false, network_access: false, @@ -172,6 +209,14 @@ describe('ui_extension', async () => { default_placement_reference: 'PLACEMENT_REFERENCE1', capabilities: undefined, preloads: {}, + build_manifest: { + assets: { + main: { + filepath: 'test-ui-extension.js', + module: './src/ExtensionPointA.js', + }, + }, + }, }, ]) }) @@ -191,6 +236,7 @@ describe('ui_extension', async () => { name: 'UI Extension', description: 'This is an ordinary test extension', type: 'ui_extension', + handle: 'test-ui-extension', capabilities: { block_progress: false, network_access: false, @@ -223,6 +269,14 @@ describe('ui_extension', async () => { default_placement_reference: undefined, capabilities: {allow_direct_linking: true}, preloads: {}, + build_manifest: { + assets: { + main: { + filepath: 'test-ui-extension.js', + module: './src/ExtensionPointA.js', + }, + }, + }, }, ]) }) @@ -242,6 +296,70 @@ describe('ui_extension', async () => { name: 'UI Extension', description: 'This is an ordinary test extension', type: 'ui_extension', + handle: 'test-ui-extension', + capabilities: { + block_progress: false, + network_access: false, + api_access: false, + collect_buyer_consent: { + customer_privacy: true, + sms_marketing: false, + }, + iframe: { + sources: [], + }, + }, + settings: {}, + } + + // When + const parsed = specification.parseConfigurationObject(configuration) + if (parsed.state !== 'ok') { + throw new Error("Couldn't parse configuration") + } + + const got = parsed.data + + // Then + expect(got.extension_points).toStrictEqual([ + { + target: 'EXTENSION::POINT::A', + module: './src/ExtensionPointA.js', + metafields: [], + default_placement_reference: undefined, + capabilities: undefined, + preloads: {chat: '/chat'}, + build_manifest: { + assets: { + main: { + filepath: 'test-ui-extension.js', + module: './src/ExtensionPointA.js', + }, + }, + }, + }, + ]) + }) + + test('build_manifest includes should_render asset when should_render.module is present', async () => { + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! + const configuration = { + targeting: [ + { + target: 'EXTENSION::POINT::A', + module: './src/ExtensionPointA.js', + should_render: { + module: './src/ShouldRender.js', + }, + preloads: {chat: '/chat', not_supported: '/hello'}, + }, + ], + api_version: '2023-01' as const, + name: 'UI Extension', + description: 'This is an ordinary test extension', + type: 'ui_extension', + handle: 'test-ui-extension', capabilities: { block_progress: false, network_access: false, @@ -274,6 +392,18 @@ describe('ui_extension', async () => { default_placement_reference: undefined, capabilities: undefined, preloads: {chat: '/chat'}, + build_manifest: { + assets: { + main: { + filepath: 'test-ui-extension.js', + module: './src/ExtensionPointA.js', + }, + should_render: { + filepath: 'test-ui-extension-conditions.js', + module: './src/ShouldRender.js', + }, + }, + }, }, ]) }) @@ -392,6 +522,14 @@ Please check the configuration in ${uiExtension.configurationPath}`), { target: 'EXTENSION::POINT::A', module: './src/ExtensionPointA.js', + build_manifest: { + assets: { + main: { + filepath: 'test-ui-extension.js', + module: './src/ExtensionPointA.js', + }, + }, + }, }, ], }) @@ -406,7 +544,18 @@ Please check the configuration in ${uiExtension.configurationPath}`), expect(loadLocales.loadLocalesConfig).toBeCalledWith(tmpDir, uiExtension.configuration.type) expect(deployConfig).toStrictEqual({ localization, - extension_points: uiExtension.configuration.extension_points, + extension_points: uiExtension.configuration.extension_points?.map((extPoint) => ({ + ...extPoint, + build_manifest: { + ...extPoint.build_manifest, + assets: { + main: { + filepath: 'dist/test-ui-extension.js', + module: extPoint.module, + }, + }, + }, + })), // Ensure nested capabilities are updated capabilities: { @@ -437,16 +586,32 @@ Please check the configuration in ${uiExtension.configurationPath}`), { target: 'EXTENSION::POINT::A', module: './src/ExtensionPointA.js', + build_manifest: { + assets: { + main: { + module: './src/ExtensionPointA.js', + filepath: '/test-ui-extension.js', + }, + }, + }, }, { target: 'EXTENSION::POINT::B', module: './src/ExtensionPointB.js', + build_manifest: { + assets: { + main: { + module: './src/ExtensionPointB.js', + filepath: '/test-ui-extension.js', + }, + }, + }, }, ], }) // When - const stdInContent = uiExtension.getBundleExtensionStdinContent() + const stdInContent = uiExtension.getBundleExtensionStdinContent().main // Then expect(stdInContent).toContain(`import './src/ExtensionPointA.js';`) diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts index a36c6a8a0d1..36d03f35199 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -1,4 +1,4 @@ -import {ExtensionFeature, createExtensionSpecification} from '../specification.js' +import {Asset, AssetIdentifier, ExtensionFeature, createExtensionSpecification} from '../specification.js' import {NewExtensionPointSchemaType, NewExtensionPointsSchema, BaseSchema} from '../schemas.js' import {loadLocalesConfig} from '../../../utilities/extensions/locales-configuration.js' import {getExtensionPointTargetSurface} from '../../../services/dev/extension/utilities.js' @@ -14,6 +14,21 @@ const validatePoints = (config: {extension_points?: unknown[]; targeting?: unkno return config.extension_points !== undefined || config.targeting !== undefined } +export interface BuildManifest { + assets: { + // Main asset is always required + [AssetIdentifier.Main]: { + filepath: string + module?: string + } + } & { + [key in AssetIdentifier]?: { + filepath: string + module?: string + } + } +} + const missingExtensionPointsMessage = 'No extension targets defined, add a `targeting` field to your configuration' export type UIExtensionSchemaType = zod.infer @@ -25,6 +40,23 @@ export const UIExtensionSchema = BaseSchema.extend({ .refine((config) => validatePoints(config), missingExtensionPointsMessage) .transform((config) => { const extensionPoints = (config.targeting ?? config.extension_points ?? []).map((targeting) => { + const buildManifest: BuildManifest = { + assets: { + [AssetIdentifier.Main]: { + filepath: `${config.handle}.js`, + module: targeting.module, + }, + ...(targeting.should_render?.module + ? { + [AssetIdentifier.ShouldRender]: { + filepath: `${config.handle}-conditions.js`, + module: targeting.should_render.module, + }, + } + : null), + }, + } + return { target: targeting.target, module: targeting.module, @@ -32,6 +64,7 @@ export const UIExtensionSchema = BaseSchema.extend({ default_placement_reference: targeting.default_placement, capabilities: targeting.capabilities, preloads: targeting.preloads ?? {}, + build_manifest: buildManifest, } }) return {...config, extension_points: extensionPoints} @@ -53,9 +86,11 @@ const uiExtensionSpec = createExtensionSpecification({ return validateUIExtensionPointConfig(directory, config.extension_points, path) }, deployConfig: async (config, directory) => { + const transformedExtensionPoints = config.extension_points.map(addDistPathToAssets) + return { api_version: config.api_version, - extension_points: config.extension_points, + extension_points: transformedExtensionPoints, capabilities: config.capabilities, name: config.name, description: config.description, @@ -64,7 +99,33 @@ const uiExtensionSpec = createExtensionSpecification({ } }, getBundleExtensionStdinContent: (config) => { - return config.extension_points.map(({module}) => `import '${module}';`).join('\n') + const main = config.extension_points + .map(({module}) => { + return `import '${module}'; ` + }) + .join('\n') + + const assets: {[key: string]: Asset} = {} + config.extension_points.forEach((extensionPoint) => { + // Start of Selection + Object.entries(extensionPoint.build_manifest.assets).forEach(([identifier, asset]) => { + if (identifier === AssetIdentifier.Main) { + return + } + + assets[identifier] = { + identifier: identifier as AssetIdentifier, + outputFileName: asset.filepath, + content: `import '${asset.module}'`, + } + }) + }) + + const assetsArray = Object.values(assets) + return { + main, + ...(assetsArray.length ? {assets: assetsArray} : {}), + } }, hasExtensionPointTarget: (config, requestedTarget) => { return ( @@ -75,6 +136,24 @@ const uiExtensionSpec = createExtensionSpecification({ }, }) +function addDistPathToAssets(extP: NewExtensionPointSchemaType & {build_manifest: BuildManifest}) { + return { + ...extP, + build_manifest: { + ...extP.build_manifest, + assets: Object.fromEntries( + Object.entries(extP.build_manifest.assets).map(([key, value]) => [ + key as AssetIdentifier, + { + ...value, + filepath: joinPath('dist', value.filepath), + }, + ]), + ), + }, + } +} + async function validateUIExtensionPointConfig( directory: string, extensionPoints: NewExtensionPointSchemaType[], diff --git a/packages/app/src/cli/services/build/extension.ts b/packages/app/src/cli/services/build/extension.ts index 08d4e0b2950..4266136eb6e 100644 --- a/packages/app/src/cli/services/build/extension.ts +++ b/packages/app/src/cli/services/build/extension.ts @@ -8,7 +8,7 @@ import {exec} from '@shopify/cli-kit/node/system' import {AbortSignal} from '@shopify/cli-kit/node/abort' import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' import lockfile from 'proper-lockfile' -import {joinPath} from '@shopify/cli-kit/node/path' +import {dirname, joinPath} from '@shopify/cli-kit/node/path' import {outputDebug} from '@shopify/cli-kit/node/output' import {readFile, touchFile, writeFile, fileExistsSync} from '@shopify/cli-kit/node/fs' import {Writable} from 'stream' @@ -91,12 +91,14 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex env.APP_URL = options.appURL } + const {main, assets} = extension.getBundleExtensionStdinContent() + try { await bundleExtension({ minify: true, outputPath: extension.outputPath, stdin: { - contents: extension.getBundleExtensionStdinContent(), + contents: main, resolveDir: extension.directory, loader: 'tsx', }, @@ -106,6 +108,25 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex stdout: options.stdout, sourceMaps: extension.isSourceMapGeneratingExtension, }) + if (assets) { + await Promise.all( + assets.map(async (asset) => { + await bundleExtension({ + minify: true, + outputPath: joinPath(dirname(extension.outputPath), asset.outputFileName), + stdin: { + contents: asset.content, + resolveDir: extension.directory, + loader: 'tsx', + }, + environment: options.environment, + env, + stderr: options.stderr, + stdout: options.stdout, + }) + }), + ) + } } catch (extensionBundlingError) { // this fails if the app's own source code is broken; wrap such that this isn't flagged as a CLI bug throw new AbortError( diff --git a/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts b/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts index a25c8b0b0f9..8a47cccb451 100644 --- a/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts +++ b/packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts @@ -406,9 +406,9 @@ describe('app-event-watcher', () => { class MockESBuildContextManager extends ESBuildContextManager { contexts = { // The keys are the extension handles, the values are the ESBuild contexts mocked - h1: {rebuild: vi.fn(), watch: vi.fn(), serve: vi.fn(), cancel: vi.fn(), dispose: vi.fn()}, - h2: {rebuild: vi.fn(), watch: vi.fn(), serve: vi.fn(), cancel: vi.fn(), dispose: vi.fn()}, - 'test-ui-extension': {rebuild: vi.fn(), watch: vi.fn(), serve: vi.fn(), cancel: vi.fn(), dispose: vi.fn()}, + h1: [{rebuild: vi.fn(), watch: vi.fn(), serve: vi.fn(), cancel: vi.fn(), dispose: vi.fn()}], + h2: [{rebuild: vi.fn(), watch: vi.fn(), serve: vi.fn(), cancel: vi.fn(), dispose: vi.fn()}], + 'test-ui-extension': [{rebuild: vi.fn(), watch: vi.fn(), serve: vi.fn(), cancel: vi.fn(), dispose: vi.fn()}], } constructor() { 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 cac89dda8e0..efc7139f330 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 @@ -228,7 +228,7 @@ export class AppEventWatcher extends EventEmitter { const ext = extEvent.extension return useConcurrentOutputContext({outputPrefix: ext.handle, stripAnsi: false}, async () => { try { - if (this.esbuildManager.contexts[ext.handle]) { + if (this.esbuildManager.contexts?.[ext.handle]?.length) { await this.esbuildManager.rebuildContext(ext) } else { await this.buildExtension(ext) diff --git a/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.test.ts b/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.test.ts index 800f6d78c5a..4c1867eb43d 100644 --- a/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.test.ts +++ b/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.test.ts @@ -35,6 +35,50 @@ describe('app-watcher-esbuild', () => { expect(manager.contexts).toHaveProperty('test-ui-extension') }) + test('creating multiple contexts for the same extension', async () => { + // Given + const options: DevAppWatcherOptions = { + dotEnvVariables: {key: 'value'}, + url: 'http://localhost:3000', + outputPath: '/path/to/output', + } + const manager = new ESBuildContextManager(options) + const extension = await testUIExtension({ + configuration: { + ...extension2.configuration, + handle: 'conditional-extension', + extension_points: [ + { + target: 'target1', + module: 'module1', + should_render: { + module: 'shouldRenderModule1', + }, + build_manifest: { + assets: { + main: { + module: 'module1', + filepath: '/conditional-extension.js', + }, + should_render: { + module: 'shouldRenderModule1', + filepath: '/conditional-extension-conditions.js', + }, + }, + }, + }, + ], + }, + }) + + // When + await manager.createContexts([extension]) + + // Then + expect(manager.contexts).toHaveProperty('conditional-extension') + expect(manager.contexts['conditional-extension']).toHaveLength(2) + }) + test('deleting contexts', async () => { // Given const manager = new ESBuildContextManager(options) @@ -76,7 +120,7 @@ describe('app-watcher-esbuild', () => { // Given const manager = new ESBuildContextManager(options) await manager.createContexts([extension1]) - const spyContext = vi.spyOn(manager.contexts.h1!, 'rebuild').mockResolvedValue({} as any) + const spyContext = vi.spyOn(manager.contexts.h1![0]!, 'rebuild').mockResolvedValue({} as any) const spyCopy = vi.spyOn(fs, 'copyFile').mockResolvedValue() // When diff --git a/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.ts b/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.ts index 2dc89991fb4..c456e4ba83a 100644 --- a/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.ts +++ b/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.ts @@ -1,10 +1,10 @@ import {AppEvent, EventType} from './app-event-watcher.js' import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {getESBuildOptions} from '../../extensions/bundle.js' -import {BuildContext, context as esContext} from 'esbuild' +import {BuildContext, context as esContext, StdinOptions} from 'esbuild' import {AbortSignal} from '@shopify/cli-kit/node/abort' import {copyFile} from '@shopify/cli-kit/node/fs' -import {dirname} from '@shopify/cli-kit/node/path' +import {dirname, joinPath} from '@shopify/cli-kit/node/path' export interface DevAppWatcherOptions { dotEnvVariables: {[key: string]: string} @@ -17,7 +17,7 @@ export interface DevAppWatcherOptions { * Has a list of all active contexts and methods to create, update and delete them. */ export class ESBuildContextManager { - contexts: {[key: string]: BuildContext} + contexts: {[key: string]: BuildContext[]} outputPath: string dotEnvVariables: {[key: string]: string} url: string @@ -33,35 +33,40 @@ export class ESBuildContextManager { setAbortSignal(signal: AbortSignal) { this.signal = signal this.signal?.addEventListener('abort', async () => { - const allDispose = Object.values(this.contexts).map((context) => context.dispose()) + const allDispose = Object.values(this.contexts) + .map((context) => context.map((ctxt) => ctxt.dispose())) + .flat() await Promise.all(allDispose) }) } async createContexts(extensions: ExtensionInstance[]) { const promises = extensions.map(async (extension) => { - const esbuildOptions = getESBuildOptions({ - minify: false, - outputPath: extension.getOutputPathForDirectory(this.outputPath), - environment: 'development', - env: { - ...this.dotEnvVariables, - APP_URL: this.url, - }, - stdin: { - contents: extension.getBundleExtensionStdinContent(), + const {main, assets} = extension.getBundleExtensionStdinContent() + const mainOutputPath = extension.getOutputPathForDirectory(this.outputPath) + const esbuildOptions = await this.extensionEsBuildOptions( + { + contents: main, resolveDir: extension.directory, loader: 'tsx', }, - logLevel: 'silent', - // stdout and stderr are mandatory, but not actually used - stderr: process.stderr, - stdout: process.stdout, - sourceMaps: true, + mainOutputPath, + ) + const mainContextPromise = esContext(esbuildOptions) + + const assetContextPromises = (assets ?? []).map(async (asset) => { + const esbuildOptions = await this.extensionEsBuildOptions( + { + contents: asset.content, + resolveDir: extension.directory, + loader: 'ts', + }, + joinPath(dirname(mainOutputPath), asset.outputFileName), + ) + return esContext(esbuildOptions) }) - const context = await esContext(esbuildOptions) - this.contexts[extension.handle] = context + this.contexts[extension.handle] = await Promise.all(assetContextPromises.concat(mainContextPromise)) }) await Promise.all(promises) @@ -70,7 +75,7 @@ export class ESBuildContextManager { async rebuildContext(extension: ExtensionInstance) { const context = this.contexts[extension.handle] if (!context) return - await context.rebuild() + await Promise.all(context.map((ctxt) => ctxt.rebuild())) // The default output path for a extension is now inside `.shopify/bundle//dist`, // all extensions output need to be under the same directory so that it can all be zipped together. @@ -97,11 +102,29 @@ export class ESBuildContextManager { } async deleteContexts(extensions: ExtensionInstance[]) { - const promises = extensions.map((ext) => this.contexts[ext.handle]?.dispose()) + const promises = extensions.map((ext) => this.contexts[ext.handle]?.map((context) => context.dispose())).flat() await Promise.all(promises) extensions.forEach((ext) => { const {[ext.handle]: _, ...rest} = this.contexts this.contexts = rest }) } + + private async extensionEsBuildOptions(stdin: StdinOptions, outputPath: string) { + return getESBuildOptions({ + minify: false, + outputPath, + environment: 'development', + env: { + ...this.dotEnvVariables, + APP_URL: this.url, + }, + stdin, + logLevel: 'silent', + // stdout and stderr are mandatory, but not actually used + stderr: process.stderr, + stdout: process.stdout, + sourceMaps: true, + }) + } } diff --git a/packages/app/src/cli/services/dev/extension/payload.test.ts b/packages/app/src/cli/services/dev/extension/payload.test.ts index 285759d9b21..e3312657e3b 100644 --- a/packages/app/src/cli/services/dev/extension/payload.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload.test.ts @@ -120,6 +120,151 @@ describe('getUIExtensionPayload', () => { }) }) + test('returns the right payload for UI Extensions with build_manifest', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const outputPath = joinPath(tmpDir, 'test-ui-extension.js') + await touchFile(outputPath) + const signal: any = vi.fn() + const stdout: any = vi.fn() + const stderr: any = vi.fn() + vi.spyOn(appModel, 'getUIExtensionRendererVersion').mockResolvedValue({ + name: 'extension-renderer', + version: '1.2.3', + }) + + const buildManifest = { + assets: { + main: {identifier: 'main', module: './src/ExtensionPointA.js', filepath: '/test-ui-extension.js'}, + should_render: { + identifier: 'should_render', + module: './src/ShouldRender.js', + filepath: '/test-ui-extension-conditions.js', + }, + }, + } + + const uiExtension = await testUIExtension({ + outputPath, + directory: tmpDir, + configuration: { + name: 'test-ui-extension', + type: 'ui_extension', + metafields: [], + capabilities: { + network_access: true, + api_access: true, + block_progress: false, + collect_buyer_consent: { + sms_marketing: false, + customer_privacy: false, + }, + iframe: { + sources: ['https://my-iframe.com'], + }, + }, + extension_points: [ + { + target: 'CUSTOM_EXTENSION_POINT', + build_manifest: buildManifest, + }, + ], + }, + devUUID: 'devUUID', + }) + + const options: Omit = { + signal, + stdout, + stderr, + apiKey: 'api-key', + appName: 'foobar', + appDirectory: '/tmp', + extensions: [uiExtension], + grantedScopes: ['scope-a'], + port: 123, + url: 'http://tunnel-url.com', + storeFqdn: 'my-domain.com', + storeId: '123456789', + buildDirectory: tmpDir, + checkoutCartUrl: 'https://my-domain.com/cart', + subscriptionProductUrl: 'https://my-domain.com/subscription', + manifestVersion: '3', + } + const development: Partial = { + hidden: true, + status: 'success', + } + + // When + const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { + ...options, + currentDevelopmentPayload: development, + }) + + // Then + expect(got).toMatchObject({ + assets: { + main: { + lastUpdated: expect.any(Number), + name: 'main', + url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension.js', + }, + }, + capabilities: { + blockProgress: false, + networkAccess: true, + apiAccess: true, + collectBuyerConsent: { + smsMarketing: false, + }, + iframe: { + sources: ['https://my-iframe.com'], + }, + }, + development: { + hidden: true, + localizationStatus: '', + resource: { + url: '', + }, + root: { + url: 'http://tunnel-url.com/extensions/devUUID', + }, + status: 'success', + }, + extensionPoints: [ + { + target: 'CUSTOM_EXTENSION_POINT', + build_manifest: buildManifest, + assets: { + main: { + lastUpdated: expect.any(Number), + name: 'main', + url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension.js', + }, + should_render: { + lastUpdated: expect.any(Number), + name: 'should_render', + url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension-conditions.js', + }, + }, + }, + ], + externalType: 'ui_extension_external', + localization: null, + metafields: null, + // as surfaces come from remote specs, we dont' have real values here + surface: 'test-surface', + title: 'test-ui-extension', + type: 'ui_extension', + uuid: 'devUUID', + version: '1.2.3', + approvalScopes: ['scope-a'], + }) + }) + }) + test('default values', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given diff --git a/packages/app/src/cli/services/dev/extension/payload.ts b/packages/app/src/cli/services/dev/extension/payload.ts index 5cbd10f9155..c721f8bbed5 100644 --- a/packages/app/src/cli/services/dev/extension/payload.ts +++ b/packages/app/src/cli/services/dev/extension/payload.ts @@ -1,12 +1,14 @@ import {getLocalization} from './localization.js' -import {DevNewExtensionPointSchema, UIExtensionPayload} from './payload/models.js' +import {Asset, DevNewExtensionPointSchema, UIExtensionPayload} from './payload/models.js' import {getExtensionPointTargetSurface} from './utilities.js' import {getUIExtensionResourceURL} from '../../../utilities/extensions/configuration.js' import {ExtensionDevOptions} from '../extension.js' import {getUIExtensionRendererVersion} from '../../../models/app/app.js' import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' +import {BuildManifest} from '../../../models/extensions/specifications/ui_extension.js' import {fileLastUpdatedTimestamp} from '@shopify/cli-kit/node/fs' import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' +import {dirname, joinPath} from '@shopify/cli-kit/node/path' export type GetUIExtensionPayloadOptions = Omit & { currentDevelopmentPayload?: Partial @@ -24,6 +26,9 @@ export async function getUIExtensionPayload( const {localization, status: localizationStatus} = await getLocalization(extension, options) const renderer = await getUIExtensionRendererVersion(extension) + + const extensionPoints = await getExtensionPoints(extension, url) + const defaultConfig = { assets: { main: { @@ -55,7 +60,7 @@ export async function getUIExtensionPayload( status: options.currentDevelopmentPayload?.status || 'success', ...(options.currentDevelopmentPayload || {status: 'success'}), }, - extensionPoints: getExtensionPoints(extension.configuration.extension_points, url), + extensionPoints, localization: localization ?? null, metafields: extension.configuration.metafields.length === 0 ? null : extension.configuration.metafields, type: extension.configuration.type, @@ -80,25 +85,48 @@ export async function getUIExtensionPayload( }) } -function getExtensionPoints(extensionPoints: ExtensionInstance['configuration']['extension_points'], url: string) { +async function getExtensionPoints(extension: ExtensionInstance, url: string) { + const extensionPoints = extension.configuration.extension_points as DevNewExtensionPointSchema[] + if (isNewExtensionPointsSchema(extensionPoints)) { - return extensionPoints.map((extensionPoint) => { - const {target, resource} = extensionPoint + return Promise.all( + extensionPoints.map(async (extensionPoint) => { + const {target, resource} = extensionPoint - return { - ...extensionPoint, - surface: getExtensionPointTargetSurface(target), - root: { - url: `${url}/${target}`, - }, - resource: resource || {url: ''}, - } - }) + return { + ...extensionPoint, + ...(extensionPoint.build_manifest + ? {assets: await extractAssetsFromBuildManifest(extensionPoint.build_manifest, url, extension)} + : {}), + surface: getExtensionPointTargetSurface(target), + root: { + url: `${url}/${target}`, + }, + resource: resource || {url: ''}, + } + }), + ) } return extensionPoints } +async function extractAssetsFromBuildManifest(buildManifest: BuildManifest, url: string, extension: ExtensionInstance) { + if (!buildManifest?.assets) return {} + const assets: {[key: string]: Asset} = {} + + for (const [name, asset] of Object.entries(buildManifest.assets)) { + assets[name] = { + name, + url: `${url}${joinPath('/assets/', asset.filepath)}`, + // eslint-disable-next-line no-await-in-loop + lastUpdated: (await fileLastUpdatedTimestamp(joinPath(dirname(extension.outputPath), asset.filepath))) ?? 0, + } + } + + return assets +} + export function isNewExtensionPointsSchema(extensionPoints: unknown): extensionPoints is DevNewExtensionPointSchema[] { return ( Array.isArray(extensionPoints) && 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 154a64ee4ce..e2716f29e6f 100644 --- a/packages/app/src/cli/services/dev/extension/payload/models.ts +++ b/packages/app/src/cli/services/dev/extension/payload/models.ts @@ -1,4 +1,5 @@ import {Localization} from '../localization.js' +import {BuildManifest} from '../../../../models/extensions/specifications/ui_extension.js' import type {NewExtensionPointSchemaType, ApiVersionSchemaType} from '../../../../models/extensions/schemas.js' interface ExtensionsPayloadInterface { @@ -25,8 +26,17 @@ export interface ExtensionsEndpointPayload extends ExtensionsPayloadInterface { url: string } } +export interface Asset { + name: string + url: string + lastUpdated: number +} export interface DevNewExtensionPointSchema extends NewExtensionPointSchemaType { + build_manifest: BuildManifest + assets: { + [name: string]: Asset + } root: { url: string } @@ -37,10 +47,7 @@ export interface DevNewExtensionPointSchema extends NewExtensionPointSchemaType export interface UIExtensionPayload { assets: { - main: { - url: string - lastUpdated: number - } + main: Asset } capabilities?: Capabilities development: { diff --git a/packages/ui-extensions-server-kit/src/types.ts b/packages/ui-extensions-server-kit/src/types.ts index 123e052a29a..a17f4d98c9e 100644 --- a/packages/ui-extensions-server-kit/src/types.ts +++ b/packages/ui-extensions-server-kit/src/types.ts @@ -94,7 +94,7 @@ export interface ExtensionPoint { localization?: FlattenedLocalization | Localization | null name: string description?: string - shouldRender?: {scriptUrl?: string} + assets?: {[name: string]: Asset} } export type ExtensionPoints = string[] | ExtensionPoint[] | null