Skip to content

Commit 5fa082f

Browse files
committed
Fix Dev Server to allow serving static assets from a shared folder outside of the extension directory
1 parent 6dd1af8 commit 5fa082f

10 files changed

Lines changed: 755 additions & 167 deletions

File tree

packages/app/src/cli/services/build/steps/include-assets-step.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import {generateManifestFile} from './include-assets/generate-manifest.js'
1+
import {generateManifestFile, resolveOutputDir} from './include-assets/generate-manifest.js'
22
import {copyByPattern} from './include-assets/copy-by-pattern.js'
33
import {copySourceEntry} from './include-assets/copy-source-entry.js'
44
import {copyConfigKeyEntry} from './include-assets/copy-config-key-entry.js'
5-
import {joinPath, dirname, extname, sanitizeRelativePath} from '@shopify/cli-kit/node/path'
5+
import {joinPath, sanitizeRelativePath} from '@shopify/cli-kit/node/path'
66
import {z} from 'zod'
77
import type {LifecycleStep, BuildContext} from '../client-steps.js'
88

@@ -123,9 +123,7 @@ export async function executeIncludeAssetsStep(
123123
): Promise<{filesCopied: number}> {
124124
const config = IncludeAssetsConfigSchema.parse(step.config)
125125
const {extension, options} = context
126-
// When outputPath is a file (e.g. index.js, index.wasm), the output directory is its
127-
// parent. When outputPath has no extension, it IS the output directory.
128-
const outputDir = extname(extension.outputPath) ? dirname(extension.outputPath) : extension.outputPath
126+
const outputDir = resolveOutputDir(extension.outputPath)
129127

130128
const aggregatedPathMap = new Map<string, string | string[]>()
131129
// Track basenames written across all configKey entries in this build to detect

packages/app/src/cli/services/build/steps/include-assets/generate-manifest.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ import {fileExists, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs'
44
import {outputDebug} from '@shopify/cli-kit/node/output'
55
import type {BuildContext} from '../../client-steps.js'
66

7+
/**
8+
* Resolves the output directory from an extension's outputPath.
9+
* When outputPath is a file (has extension), uses dirname. Otherwise uses outputPath directly.
10+
*/
11+
export function resolveOutputDir(outputPath: string): string {
12+
return extname(outputPath) ? dirname(outputPath) : outputPath
13+
}
14+
715
interface ConfigKeyManifestEntry {
816
anchor?: string | undefined
917
groupBy?: string | undefined
@@ -121,12 +129,7 @@ export async function createOrUpdateManifestFile(
121129
context: BuildContext,
122130
entries: {[key: string]: unknown},
123131
): Promise<void> {
124-
const outputPath = context.extension.outputPath
125-
/**
126-
* Resolves the output directory from an extension's outputPath.
127-
* When outputPath is a file (has extension), uses dirname. Otherwise uses outputPath directly.
128-
*/
129-
const outputDir = extname(outputPath) ? dirname(outputPath) : outputPath
132+
const outputDir = resolveOutputDir(context.extension.outputPath)
130133

131134
const manifestPath = joinPath(outputDir, 'manifest.json')
132135

packages/app/src/cli/services/dev/extension.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ describe('devUIExtensions()', () => {
6161
expect(store.ExtensionsPayloadStore).toHaveBeenCalledWith(
6262
{mock: 'payload'},
6363
{...options, websocketURL: 'wss://mock.url/extensions'},
64+
expect.any(Map),
6465
)
6566
})
6667

packages/app/src/cli/services/dev/extension.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,9 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise<voi
125125
// affecting the original `options` object and we only need to care about `payloadOptions` in this function.
126126

127127
const bundlePath = payloadOptions.appWatcher.buildOutputPath
128-
const payloadStoreRawPayload = await getExtensionsPayloadStoreRawPayload(payloadOptions, bundlePath)
129-
const payloadStore = new ExtensionsPayloadStore(payloadStoreRawPayload, payloadOptions)
128+
const assetResolvers = new Map()
129+
const payloadStoreRawPayload = await getExtensionsPayloadStoreRawPayload(payloadOptions, bundlePath, assetResolvers)
130+
const payloadStore = new ExtensionsPayloadStore(payloadStoreRawPayload, payloadOptions, assetResolvers)
130131
let extensions = payloadOptions.extensions.filter((ext) => ext.isPreviewable)
131132

132133
const getExtensions = () => {

packages/app/src/cli/services/dev/extension/payload.test.ts

Lines changed: 208 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,12 @@ describe('getUIExtensionPayload', () => {
192192
assets: {
193193
tools: {
194194
name: 'tools',
195-
url: 'http://tunnel-url.com/extensions/devUUID/assets/tools.json',
195+
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/tools.json',
196196
lastUpdated: expect.any(Number),
197197
},
198198
instructions: {
199199
name: 'instructions',
200-
url: 'http://tunnel-url.com/extensions/devUUID/assets/instructions.md',
200+
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/instructions.md',
201201
lastUpdated: expect.any(Number),
202202
},
203203
},
@@ -241,12 +241,12 @@ describe('getUIExtensionPayload', () => {
241241
assets: {
242242
main: {
243243
name: 'main',
244-
url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension.js',
244+
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/main.js',
245245
lastUpdated: expect.any(Number),
246246
},
247247
should_render: {
248248
name: 'should_render',
249-
url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension-conditions.js',
249+
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/should_render.js',
250250
lastUpdated: expect.any(Number),
251251
},
252252
},
@@ -295,12 +295,12 @@ describe('getUIExtensionPayload', () => {
295295
assets: {
296296
main: {
297297
name: 'main',
298-
url: 'http://tunnel-url.com/extensions/devUUID/assets/dist/test-ui-extension.js',
298+
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/main.js',
299299
lastUpdated: expect.any(Number),
300300
},
301301
should_render: {
302302
name: 'should_render',
303-
url: 'http://tunnel-url.com/extensions/devUUID/assets/dist/test-ui-extension-conditions.js',
303+
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/should_render.js',
304304
lastUpdated: expect.any(Number),
305305
},
306306
},
@@ -309,6 +309,205 @@ describe('getUIExtensionPayload', () => {
309309
})
310310
})
311311

312+
test('emits a distinct URL per extension point even when built assets share a filepath', async () => {
313+
await inTemporaryDirectory(async (tmpDir) => {
314+
const uiExtension = await testUIExtension({
315+
directory: tmpDir,
316+
configuration: {
317+
name: 'test-ui-extension',
318+
type: 'ui_extension',
319+
extension_points: [
320+
{target: 'TARGET_A', module: './src/ExtensionPointA.js'},
321+
{target: 'TARGET_B', module: './src/ExtensionPointB.js'},
322+
],
323+
},
324+
devUUID: 'devUUID',
325+
})
326+
327+
await setupBuildOutput(
328+
uiExtension,
329+
tmpDir,
330+
{
331+
TARGET_A: {main: 'dist/main.js'},
332+
TARGET_B: {main: 'dist/main.js'},
333+
},
334+
{},
335+
)
336+
337+
const resolver = new Map<string, string>()
338+
const got = await getUIExtensionPayload(
339+
uiExtension,
340+
tmpDir,
341+
{
342+
...createMockOptions(tmpDir, [uiExtension]),
343+
currentDevelopmentPayload: {hidden: true, status: 'success'},
344+
},
345+
resolver,
346+
)
347+
348+
expect(got.extensionPoints).toMatchObject([
349+
{
350+
target: 'TARGET_A',
351+
assets: {
352+
main: {name: 'main', url: 'http://tunnel-url.com/extensions/devUUID/assets/TARGET_A/main.js'},
353+
},
354+
},
355+
{
356+
target: 'TARGET_B',
357+
assets: {
358+
main: {name: 'main', url: 'http://tunnel-url.com/extensions/devUUID/assets/TARGET_B/main.js'},
359+
},
360+
},
361+
])
362+
expect(resolver.get('TARGET_A/main.js')).toBe('dist/main.js')
363+
expect(resolver.get('TARGET_B/main.js')).toBe('dist/main.js')
364+
})
365+
})
366+
367+
test('emits a directory-prefix URL and per-file resolver entries when the config points at a folder', async () => {
368+
await inTemporaryDirectory(async (tmpDir) => {
369+
const uiExtension = await testUIExtension({
370+
directory: tmpDir,
371+
configuration: {
372+
name: 'test-ui-extension',
373+
type: 'ui_extension',
374+
extension_points: [
375+
{
376+
target: 'CUSTOM_EXTENSION_POINT',
377+
module: './src/ExtensionPointA.js',
378+
assets: './assets',
379+
},
380+
],
381+
},
382+
devUUID: 'devUUID',
383+
})
384+
385+
await setupBuildOutput(
386+
uiExtension,
387+
tmpDir,
388+
{CUSTOM_EXTENSION_POINT: {assets: ['foo.json', 'subdir/bar.png']}},
389+
{'foo.json': '{}', 'subdir/bar.png': 'stub'},
390+
)
391+
392+
const extensionOutputPath = uiExtension.getOutputPathForDirectory(tmpDir)
393+
const buildDirectory = extname(extensionOutputPath) ? dirname(extensionOutputPath) : extensionOutputPath
394+
await writeFile(joinPath(buildDirectory, 'foo.json'), '{}')
395+
await mkdir(joinPath(buildDirectory, 'subdir'))
396+
await writeFile(joinPath(buildDirectory, 'subdir/bar.png'), 'stub')
397+
398+
const resolver = new Map<string, string>()
399+
const got = await getUIExtensionPayload(
400+
uiExtension,
401+
tmpDir,
402+
{
403+
...createMockOptions(tmpDir, [uiExtension]),
404+
currentDevelopmentPayload: {hidden: true, status: 'success'},
405+
},
406+
resolver,
407+
)
408+
409+
expect(got.extensionPoints).toMatchObject([
410+
{
411+
target: 'CUSTOM_EXTENSION_POINT',
412+
assets: {
413+
assets: {
414+
name: 'assets',
415+
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/assets/',
416+
lastUpdated: expect.any(Number),
417+
},
418+
},
419+
},
420+
])
421+
expect(resolver.get('CUSTOM_EXTENSION_POINT/assets/foo.json')).toBe('foo.json')
422+
expect(resolver.get('CUSTOM_EXTENSION_POINT/assets/subdir/bar.png')).toBe('subdir/bar.png')
423+
})
424+
})
425+
426+
test('emits distinct directory URLs per extension point when two targets share the same assets folder', async () => {
427+
await inTemporaryDirectory(async (tmpDir) => {
428+
const uiExtension = await testUIExtension({
429+
directory: tmpDir,
430+
configuration: {
431+
name: 'test-ui-extension',
432+
type: 'ui_extension',
433+
extension_points: [
434+
{target: 'TARGET_A', module: './src/ExtensionPointA.js', assets: './assets'},
435+
{target: 'TARGET_B', module: './src/ExtensionPointB.js', assets: './assets'},
436+
],
437+
},
438+
devUUID: 'devUUID',
439+
})
440+
441+
await setupBuildOutput(
442+
uiExtension,
443+
tmpDir,
444+
{
445+
TARGET_A: {assets: ['foo.json']},
446+
TARGET_B: {assets: ['foo.json']},
447+
},
448+
{'foo.json': '{}'},
449+
)
450+
const extensionOutputPath = uiExtension.getOutputPathForDirectory(tmpDir)
451+
const buildDirectory = extname(extensionOutputPath) ? dirname(extensionOutputPath) : extensionOutputPath
452+
await writeFile(joinPath(buildDirectory, 'foo.json'), '{}')
453+
454+
const resolver = new Map<string, string>()
455+
const got = await getUIExtensionPayload(
456+
uiExtension,
457+
tmpDir,
458+
{
459+
...createMockOptions(tmpDir, [uiExtension]),
460+
currentDevelopmentPayload: {hidden: true, status: 'success'},
461+
},
462+
resolver,
463+
)
464+
465+
expect(got.extensionPoints).toMatchObject([
466+
{
467+
target: 'TARGET_A',
468+
assets: {assets: {url: 'http://tunnel-url.com/extensions/devUUID/assets/TARGET_A/assets/'}},
469+
},
470+
{
471+
target: 'TARGET_B',
472+
assets: {assets: {url: 'http://tunnel-url.com/extensions/devUUID/assets/TARGET_B/assets/'}},
473+
},
474+
])
475+
// Both targets' resolver entries point at the same output-relative file.
476+
expect(resolver.get('TARGET_A/assets/foo.json')).toBe('foo.json')
477+
expect(resolver.get('TARGET_B/assets/foo.json')).toBe('foo.json')
478+
})
479+
})
480+
481+
test('clears stale resolver entries on each payload regeneration', async () => {
482+
await inTemporaryDirectory(async (tmpDir) => {
483+
const uiExtension = await testUIExtension({
484+
directory: tmpDir,
485+
configuration: {
486+
name: 'test-ui-extension',
487+
type: 'ui_extension',
488+
extension_points: [{target: 'CUSTOM_EXTENSION_POINT', module: './src/ExtensionPointA.js'}],
489+
},
490+
devUUID: 'devUUID',
491+
})
492+
493+
await setupBuildOutput(uiExtension, tmpDir, {CUSTOM_EXTENSION_POINT: {main: 'dist/main.js'}}, {})
494+
495+
const resolver = new Map<string, string>([['STALE_TARGET/main.js', 'stale.js']])
496+
await getUIExtensionPayload(
497+
uiExtension,
498+
tmpDir,
499+
{
500+
...createMockOptions(tmpDir, [uiExtension]),
501+
currentDevelopmentPayload: {hidden: true, status: 'success'},
502+
},
503+
resolver,
504+
)
505+
506+
expect(resolver.has('STALE_TARGET/main.js')).toBe(false)
507+
expect(resolver.get('CUSTOM_EXTENSION_POINT/main.js')).toBe('dist/main.js')
508+
})
509+
})
510+
312511
test('maps intents from manifest.json to asset payloads', async () => {
313512
await inTemporaryDirectory(async (tmpDir) => {
314513
const uiExtension = await testUIExtension({
@@ -351,7 +550,7 @@ describe('getUIExtensionPayload', () => {
351550
action: 'create',
352551
schema: {
353552
name: 'schema',
354-
url: 'http://tunnel-url.com/extensions/devUUID/assets/intents/create-schema.json',
553+
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/intents/0/schema.json',
355554
lastUpdated: expect.any(Number),
356555
},
357556
},
@@ -360,7 +559,7 @@ describe('getUIExtensionPayload', () => {
360559
action: 'update',
361560
schema: {
362561
name: 'schema',
363-
url: 'http://tunnel-url.com/extensions/devUUID/assets/intents/update-schema.json',
562+
url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/intents/1/schema.json',
364563
lastUpdated: expect.any(Number),
365564
},
366565
},
@@ -461,7 +660,7 @@ describe('getUIExtensionPayload', () => {
461660
assets: {
462661
tools: {
463662
name: 'tools',
464-
url: 'http://tunnel-url.com/extensions/devUUID/assets/tools.json',
663+
url: 'http://tunnel-url.com/extensions/devUUID/assets/admin.app.intent.link/tools.json',
465664
lastUpdated: expect.any(Number),
466665
},
467666
},

0 commit comments

Comments
 (0)