Skip to content
Open
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
3 changes: 2 additions & 1 deletion packages/app/src/cli/models/extensions/specification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,12 @@ export function createContractBasedModuleSpecification<TConfiguration extends Ba
| 'clientSteps'
| 'experience'
| 'transformRemoteToLocal'
| 'schema'
>,
) {
return createExtensionSpecification({
identifier: spec.identifier,
schema: zod.any({}) as unknown as ZodSchemaType<TConfiguration>,
schema: spec.schema ?? (zod.any({}) as unknown as ZodSchemaType<TConfiguration>),
appModuleFeatures: spec.appModuleFeatures,
experience: spec.experience,
buildConfig: spec.buildConfig ?? {mode: 'none'},
Expand Down
15 changes: 14 additions & 1 deletion packages/app/src/cli/models/extensions/specifications/admin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import {createContractBasedModuleSpecification} from '../specification.js'
import {ZodSchemaType, BaseConfigType} from '../schemas.js'
import {zod} from '@shopify/cli-kit/node/schema'

const AdminSchema = zod.object({
admin: zod
.object({
static_root: zod.string().optional(),
})
.optional(),
})

const adminSpecificationSpec = createContractBasedModuleSpecification({
identifier: 'admin',
uidStrategy: 'single',
experience: 'configuration',
schema: AdminSchema as unknown as ZodSchemaType<BaseConfigType>,
transformRemoteToLocal: (remoteContent) => {
return {
admin: {
Expand All @@ -24,7 +36,8 @@ const adminSpecificationSpec = createContractBasedModuleSpecification({
name: 'Hosted App Copy Files',
type: 'include_assets',
config: {
generatesAssetsManifest: true,
// Remove this until we fix the bug related to recreating the manifest during dev
generatesAssetsManifest: false,
inclusions: [
{
type: 'configKey',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export async function handleWatcherEvents(
const affectedExtensions = app.realExtensions.filter((ext) => ext.directory === event.extensionPath)
const newEvent = handlers[event.type]({event, app: appEvent.app, extensions: affectedExtensions, options})
appEvent.extensionEvents.push(...newEvent.extensionEvents)
if (newEvent.appAssetsUpdated) appEvent.appAssetsUpdated = true
}

return appEvent
Expand Down Expand Up @@ -61,6 +62,7 @@ const handlers: {[key in WatcherEvent['type']]: Handler} = {
file_deleted: FileChangeHandler,
file_updated: FileChangeHandler,
app_config_deleted: AppConfigDeletedHandler,
app_asset_updated: AppAssetUpdatedHandler,
// These two are processed manually to avoid multiple reloads
extension_folder_created: EmptyHandler,
extensions_config_updated: EmptyHandler,
Expand Down Expand Up @@ -91,6 +93,17 @@ function FileChangeHandler({event, app, extensions}: HandlerInput): AppEvent {
return {app, extensionEvents: events, startTime: event.startTime, path: event.path}
}

/**
* When a file inside an app asset directory (e.g. static_root) is updated:
* Find the owning extension via extensionPath and return an Updated event so it gets rebuilt.
* The rebuild runs the include_assets step which copies the changed files into the bundle.
*/
function AppAssetUpdatedHandler({event, app}: HandlerInput): AppEvent {
const adminExtension = app.realExtensions.find((ext) => ext.specification.identifier === 'admin')
const events: ExtensionEvent[] = adminExtension ? [{type: EventType.Updated, extension: adminExtension}] : []
return {app, extensionEvents: events, startTime: event.startTime, path: event.path, appAssetsUpdated: true}
}

/**
* When an event doesn't require any action, return the same app and an empty event.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export interface AppEvent {
path: string
startTime: [number, number]
appWasReloaded?: boolean
appAssetsUpdated?: boolean
}

type ExtensionBuildResult = {status: 'ok'; uid: string} | {status: 'error'; error: string; file?: string; uid: string}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,15 +263,15 @@ describe('file-watcher events', () => {

// Then
expect(watchSpy).toHaveBeenCalledWith([joinPath(dir, '/shopify.app.toml'), joinPath(dir, '/extensions')], {
ignored: [
ignored: expect.arrayContaining([
'**/node_modules/**',
'**/.git/**',
'**/*.test.*',
'**/dist/**',
'**/*.swp',
'**/generated/**',
'**/.gitignore',
],
expect.any(Function),
]),
ignoreInitial: true,
persistent: true,
})
Expand Down
65 changes: 63 additions & 2 deletions packages/app/src/cli/services/dev/app-events/file-watcher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable no-case-declarations */
import {AppLinkedInterface} from '../../../models/app/app.js'
import {configurationFileNames} from '../../../constants.js'
import {ExtensionInstance} from '../../../models/extensions/extension-instance.js'
import {dirname, joinPath, normalizePath, relativePath} from '@shopify/cli-kit/node/path'
import {FSWatcher} from 'chokidar'
import {outputDebug} from '@shopify/cli-kit/node/output'
Expand All @@ -9,6 +10,7 @@ import {startHRTime, StartTime} from '@shopify/cli-kit/node/hrtime'
import {fileExistsSync, matchGlob, mkdir, readFileSync} from '@shopify/cli-kit/node/fs'
import {debounce} from '@shopify/cli-kit/common/function'
import ignore from 'ignore'
import {getPathValue} from '@shopify/cli-kit/common/object'
import {Writable} from 'stream'

const DEFAULT_DEBOUNCE_TIME_IN_MS = 200
Expand Down Expand Up @@ -36,6 +38,7 @@ export interface WatcherEvent {
| 'file_deleted'
| 'extensions_config_updated'
| 'app_config_deleted'
| 'app_asset_updated'
path: string
extensionPath: string
startTime: StartTime
Expand All @@ -58,6 +61,8 @@ export class FileWatcher {
private readonly ignored: {[key: string]: ignore.Ignore | undefined} = {}
// Map of file paths to the extensions that watch them
private readonly extensionWatchedFiles = new Map<string, Set<string>>()
// Map of asset directory path to the extension directory that owns it
private appAssetToExtensionDir = new Map<string, string>()

constructor(
app: AppLinkedInterface,
Expand Down Expand Up @@ -104,7 +109,9 @@ export class FileWatcher {
}),
)

this.appAssetToExtensionDir = this.resolveAppAssetWatchPaths(this.app.realExtensions)
const watchPaths = [this.app.configPath, ...fullExtensionDirectories]
Array.from(this.appAssetToExtensionDir.keys()).forEach((key) => watchPaths.push(key))

// Get all watched files from extensions
const allWatchedFiles = this.getAllWatchedFiles()
Expand All @@ -114,15 +121,24 @@ export class FileWatcher {

// Create new watcher
const {default: chokidar} = await import('chokidar')
const appAssetDirs = [...this.appAssetToExtensionDir.keys()]
this.watcher = chokidar.watch(watchPaths, {
ignored: [
'**/node_modules/**',
'**/.git/**',
'**/*.test.*',
'**/dist/**',
'**/*.swp',
'**/generated/**',
'**/.gitignore',
// Ignore files inside dist/ directories, unless the path falls under a watched
// app asset directory (e.g. static_root may point to a dist/ folder).
// Non-dist paths are never ignored here (return false). For dist paths, we only
// allow them through if they are inside one of the app asset directories.
(filePath: string) => {
const normalized = normalizePath(filePath)
if (!normalized.includes('/dist/') && !normalized.endsWith('/dist')) return false
return !appAssetDirs.some((assetDir) => normalized.startsWith(assetDir))
},
],
persistent: true,
ignoreInitial: true,
Expand Down Expand Up @@ -177,6 +193,36 @@ export class FileWatcher {
return Array.from(allFiles)
}

/**
* Resolves app asset directories that should be watched.
* Returns a map of absolute asset directory path → owning extension directory.
*/
private resolveAppAssetWatchPaths(allExtensions: ExtensionInstance[]): Map<string, string> {
const result = new Map<string, string>()
const adminExtension = allExtensions.find((ext) => ext.specification.identifier === 'admin')
if (adminExtension) {
const staticRootPath = getPathValue<string>(adminExtension.configuration, 'admin.static_root')
if (staticRootPath) {
const absolutePath = joinPath(adminExtension.directory, staticRootPath)
result.set(normalizePath(absolutePath), normalizePath(adminExtension.directory))
}
}
return result
}

/**
* Checks if a file path is inside any app asset directory.
* Returns the owning extension directory if found, undefined otherwise.
*/
private findAppAssetExtensionDir(filePath: string): string | undefined {
for (const [assetDir, extensionDir] of this.appAssetToExtensionDir) {
if (filePath.startsWith(assetDir)) {
return extensionDir
}
}
return undefined
}

/**
* Emits the accumulated events and resets the current events list.
* It also logs the number of events emitted and their paths for debugging purposes.
Expand Down Expand Up @@ -227,7 +273,12 @@ export class FileWatcher {
* Explicit watch paths have priority over custom gitignore files
*/
private shouldIgnoreEvent(event: WatcherEvent) {
if (event.type === 'extension_folder_deleted' || event.type === 'extension_folder_created') return false
if (
event.type === 'extension_folder_deleted' ||
event.type === 'extension_folder_created' ||
event.type === 'app_asset_updated'
)
return false

const extension = this.app.realExtensions.find((ext) => ext.directory === event.extensionPath)
const watchPaths = extension?.watchedFiles()
Expand Down Expand Up @@ -258,6 +309,16 @@ export class FileWatcher {
const affectedExtensions = this.extensionWatchedFiles.get(normalizedPath)
const isUnknownExtension = affectedExtensions === undefined || affectedExtensions.size === 0

// Check if the file is inside an app asset directory (e.g. static_root)
const appAssetExtensionDir = this.findAppAssetExtensionDir(normalizedPath)
if (appAssetExtensionDir) {
if (event === 'change' || event === 'add' || event === 'unlink') {
this.pushEvent({type: 'app_asset_updated', path, extensionPath: appAssetExtensionDir, startTime})
}
this.debouncedEmit()
return
}

if (isUnknownExtension && !isExtensionToml && !isConfigAppPath) {
// Ignore an event if it's not part of an existing extension
// Except if it is a toml file (either app config or extension config)
Expand Down
105 changes: 90 additions & 15 deletions packages/app/src/cli/services/dev/extension.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -33,7 +33,10 @@ describe('devUIExtensions()', () => {

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
vi.spyOn(store, 'ExtensionsPayloadStore').mockImplementation(() => ({mock: 'payload-store'}))
vi.spyOn(store, 'ExtensionsPayloadStore').mockImplementation(() => ({
mock: 'payload-store',
updateAppAssets: vi.fn(),
}))
vi.spyOn(server, 'setupHTTPServer').mockReturnValue({
mock: 'http-server',
close: serverCloseSpy,
Expand Down Expand Up @@ -65,11 +68,13 @@ describe('devUIExtensions()', () => {
await devUIExtensions(options)

// THEN
expect(server.setupHTTPServer).toHaveBeenCalledWith({
devOptions: {...options, websocketURL: 'wss://mock.url/extensions'},
payloadStore: {mock: 'payload-store'},
getExtensions: expect.any(Function),
})
expect(server.setupHTTPServer).toHaveBeenCalledWith(
expect.objectContaining({
devOptions: expect.objectContaining({websocketURL: 'wss://mock.url/extensions'}),
payloadStore: expect.objectContaining({mock: 'payload-store'}),
getExtensions: expect.any(Function),
}),
)
})

test('initializes the HTTP server with a getExtensions function that returns the extensions from the provided options', async () => {
Expand All @@ -91,12 +96,13 @@ describe('devUIExtensions()', () => {
await devUIExtensions(options)

// THEN
expect(websocket.setupWebsocketConnection).toHaveBeenCalledWith({
...options,
httpServer: expect.objectContaining({mock: 'http-server'}),
payloadStore: {mock: 'payload-store'},
websocketURL: 'wss://mock.url/extensions',
})
expect(websocket.setupWebsocketConnection).toHaveBeenCalledWith(
expect.objectContaining({
httpServer: expect.objectContaining({mock: 'http-server'}),
payloadStore: expect.objectContaining({mock: 'payload-store'}),
websocketURL: 'wss://mock.url/extensions',
}),
)
})

test('closes the http server, websocket and bundler when the process aborts', async () => {
Expand Down Expand Up @@ -128,14 +134,83 @@ describe('devUIExtensions()', () => {
const {getExtensions} = vi.mocked(server.setupHTTPServer).mock.calls[0]![0]
expect(getExtensions()).toStrictEqual(options.extensions)

const newUIExtension = {type: 'ui_extension', devUUID: 'BAR', isPreviewable: true}
const newUIExtension = {
type: 'ui_extension',
devUUID: 'BAR',
isPreviewable: true,
specification: {identifier: 'ui_extension'},
}
const newApp = {
...app,
allExtensions: [newUIExtension, {type: 'function_extension', devUUID: 'FUNCTION', isPreviewable: false}],
allExtensions: [
newUIExtension,
{
type: 'function_extension',
devUUID: 'FUNCTION',
isPreviewable: false,
specification: {identifier: 'function'},
},
],
}
options.appWatcher.emit('all', {app: newApp, appWasReloaded: true, extensionEvents: []})

// THEN
expect(getExtensions()).toStrictEqual([newUIExtension])
})

test('passes getAppAssets callback to the HTTP server when appAssets provided', async () => {
// GIVEN
spyOnEverything()
const optionsWithAssets = {
...options,
appAssets: {staticRoot: '/absolute/path/to/public'},
} as unknown as ExtensionDevOptions

// WHEN
await devUIExtensions(optionsWithAssets)

// THEN
expect(server.setupHTTPServer).toHaveBeenCalledWith(
expect.objectContaining({
getAppAssets: expect.any(Function),
}),
)

const {getAppAssets} = vi.mocked(server.setupHTTPServer).mock.calls[0]![0]
expect(getAppAssets!()).toStrictEqual({staticRoot: '/absolute/path/to/public'})
})
})

describe('resolveAppAssets()', () => {
test('returns empty object when no admin extension exists', () => {
const extensions = [
{specification: {identifier: 'ui_extension'}, configuration: {}, directory: '/app'},
] as unknown as Parameters<typeof resolveAppAssets>[0]

expect(resolveAppAssets(extensions)).toStrictEqual({})
})

test('returns empty object when admin extension has no static_root', () => {
const extensions = [
{specification: {identifier: 'admin'}, configuration: {admin: {}}, directory: '/app/extensions/admin'},
] as unknown as Parameters<typeof resolveAppAssets>[0]

expect(resolveAppAssets(extensions)).toStrictEqual({})
})

test('returns staticRoot mapped to resolved absolute path when static_root is set', () => {
const extensions = [
{
specification: {identifier: 'admin'},
configuration: {admin: {static_root: 'public'}},
directory: '/app/extensions/admin',
},
] as unknown as Parameters<typeof resolveAppAssets>[0]

const result = resolveAppAssets(extensions)

expect(result).toStrictEqual({
staticRoot: '/app/extensions/admin/public',
})
})
})
Loading
Loading