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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/app/src/cli/models/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export interface AppInterface<
realExtensions: ExtensionInstance[]
nonConfigExtensions: ExtensionInstance[]
draftableExtensions: ExtensionInstance[]
appAssetsConfigs: Record<string, string> | undefined
errors: AppErrors
hiddenConfig: AppHiddenConfig
includeConfigOnDeploy: boolean | undefined
Expand Down Expand Up @@ -334,6 +335,15 @@ export class App<
)
}

get appAssetsConfigs(): Record<string, string> | undefined {
if (!this.realExtensions.some((ext) => ext.specification.appAssetsConfig)) return undefined
return this.realExtensions.reduce<Record<string, string>>((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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
return this.specification.devSessionWatchConfig(this)
}

return this.specification.experience === 'configuration' ? {paths: []} : undefined
return this.isAppConfigExtension ? {paths: []} : undefined
}

async watchConfigurationPaths() {
Expand Down
13 changes: 13 additions & 0 deletions packages/app/src/cli/models/extensions/specification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,19 @@ export interface ExtensionSpecification<TConfiguration extends BaseConfigType =
* or undefined to watch all files in the extension directory.
*/
devSessionWatchConfig?: (extension: ExtensionInstance<TConfiguration>) => 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ const adminSpecificationSpec = createExtensionSpecification<AdminConfigType>({
},
],
appModuleFeatures: () => [],
appAssetsConfig: (config) => {
const dir = config.admin?.static_root
if (!dir) return undefined
return {assetsKey: 'staticRoot', assetsDir: dir}
},
})

export default adminSpecificationSpec
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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/')
Expand All @@ -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),
)
})

Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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`.
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}))
Expand Down
22 changes: 21 additions & 1 deletion packages/app/src/cli/services/dev/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, string>
}

export async function devUIExtensions(options: ExtensionDevOptions): Promise<void> {
Expand All @@ -133,7 +139,13 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise<voi
}

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

outputDebug(`Setting up the UI extensions Websocket server...`, payloadOptions.stdout)
const websocketConnection = setupWebsocketConnection({...payloadOptions, httpServer, payloadStore})
Expand All @@ -144,6 +156,14 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise<voi
extensions = app.allExtensions.filter((ext) => 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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ interface ExtensionsPayloadInterface {
url: string
mobileUrl: string
title: string
assets?: {
[key: string]: {
url: string
lastUpdated: number
}
}
}
appId?: string
store: string
Expand Down
23 changes: 22 additions & 1 deletion packages/app/src/cli/services/dev/extension/payload/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {EventEmitter} from 'events'

export interface ExtensionsPayloadStoreOptions extends ExtensionDevOptions {
websocketURL: string
appAssets?: Record<string, string>
}

export enum ExtensionsPayloadStoreEvent {
Expand All @@ -19,7 +20,7 @@ export async function getExtensionsPayloadStoreRawPayload(
options: Omit<ExtensionsPayloadStoreOptions, 'appWatcher'>,
bundlePath: string,
): Promise<ExtensionsEndpointPayload> {
return {
const payload: ExtensionsEndpointPayload = {
app: {
title: options.appName,
apiKey: options.apiKey,
Expand All @@ -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<string, {url: string; lastUpdated: number}> = {}
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 {
Expand Down Expand Up @@ -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)
}
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/cli/services/dev/extension/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
corsMiddleware,
devConsoleAssetsMiddleware,
devConsoleIndexMiddleware,
getAppAssetsMiddleware,
getExtensionAssetMiddleware,
getExtensionPayloadMiddleware,
getExtensionPointMiddleware,
Expand All @@ -19,6 +20,7 @@ interface SetupHTTPServerOptions {
devOptions: ExtensionsPayloadStoreOptions
payloadStore: ExtensionsPayloadStore
getExtensions: () => ExtensionInstance[]
getAppAssets?: () => Record<string, string> | undefined
}

export function setupHTTPServer(options: SetupHTTPServerOptions) {
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,20 @@ export const devConsoleAssetsMiddleware = defineEventHandler(async (event) => {
})
})

export function getAppAssetsMiddleware(getAppAssets: () => Record<string, string> | 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface PreviewableExtensionOptions {
grantedScopes: string[]
previewableExtensions: ExtensionInstance[]
appWatcher: AppEventWatcher
appAssetsConfigs: Record<string, string> | undefined
}

export interface PreviewableExtensionProcess extends BaseProcess<PreviewableExtensionOptions> {
Expand All @@ -47,6 +48,7 @@ export const launchPreviewableExtensionProcess: DevProcessFunction<PreviewableEx
previewableExtensions,
appDirectory,
appWatcher,
appAssetsConfigs,
},
) => {
await devUIExtensions({
Expand All @@ -68,6 +70,7 @@ export const launchPreviewableExtensionProcess: DevProcessFunction<PreviewableEx
subscriptionProductUrl,
manifestVersion: MANIFEST_VERSION,
appWatcher,
appAssets: appAssetsConfigs,
})
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export async function setupDevProcesses({
})
: undefined,
await setupPreviewableExtensionsProcess({
allExtensions: reloadedApp.allExtensions,
allExtensions: reloadedApp.realExtensions,
storeFqdn,
storeId,
apiKey,
Expand All @@ -162,6 +162,7 @@ export async function setupDevProcesses({
appId: remoteApp.id,
appDirectory: reloadedApp.directory,
appWatcher,
appAssetsConfigs: reloadedApp.appAssetsConfigs,
}),
developerPlatformClient.supportsDevSessions
? await setupDevSessionProcess({
Expand Down
6 changes: 6 additions & 0 deletions packages/ui-extensions-server-kit/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,10 @@ export interface App {
}
supportEmail?: string
supportLocales?: string[]
assets?: {
[key: string]: {
url: string
lastUpdated: number
}
}
}
Loading