From 5deb0e53ae5d6c255458f507c098aa79ce613902 Mon Sep 17 00:00:00 2001 From: Trish Ta Date: Thu, 23 Apr 2026 17:54:46 -0400 Subject: [PATCH] Fix Dev Server to allow serving static assets from a shared folder outside of the extension directory --- .../build/steps/include-assets-step.ts | 8 +- .../steps/include-assets/generate-manifest.ts | 15 +- .../src/cli/services/dev/extension.test.ts | 1 + .../app/src/cli/services/dev/extension.ts | 5 +- .../services/dev/extension/payload.test.ts | 217 ++++++++++++++- .../src/cli/services/dev/extension/payload.ts | 245 ++++++++++++----- .../dev/extension/payload/store.test.ts | 68 +++-- .../services/dev/extension/payload/store.ts | 59 +++- .../dev/extension/server/middlewares.test.ts | 253 ++++++++++++++++-- .../dev/extension/server/middlewares.ts | 51 ++-- 10 files changed, 755 insertions(+), 167 deletions(-) diff --git a/packages/app/src/cli/services/build/steps/include-assets-step.ts b/packages/app/src/cli/services/build/steps/include-assets-step.ts index 7aa8a41bab..c6bd77740c 100644 --- a/packages/app/src/cli/services/build/steps/include-assets-step.ts +++ b/packages/app/src/cli/services/build/steps/include-assets-step.ts @@ -1,8 +1,8 @@ -import {generateManifestFile} from './include-assets/generate-manifest.js' +import {generateManifestFile, resolveOutputDir} from './include-assets/generate-manifest.js' import {copyByPattern} from './include-assets/copy-by-pattern.js' import {copySourceEntry} from './include-assets/copy-source-entry.js' import {copyConfigKeyEntry} from './include-assets/copy-config-key-entry.js' -import {joinPath, dirname, extname, sanitizeRelativePath} from '@shopify/cli-kit/node/path' +import {joinPath, sanitizeRelativePath} from '@shopify/cli-kit/node/path' import {z} from 'zod' import type {LifecycleStep, BuildContext} from '../client-steps.js' @@ -123,9 +123,7 @@ export async function executeIncludeAssetsStep( ): Promise<{filesCopied: number}> { const config = IncludeAssetsConfigSchema.parse(step.config) const {extension, options} = context - // When outputPath is a file (e.g. index.js, index.wasm), the output directory is its - // parent. When outputPath has no extension, it IS the output directory. - const outputDir = extname(extension.outputPath) ? dirname(extension.outputPath) : extension.outputPath + const outputDir = resolveOutputDir(extension.outputPath) const aggregatedPathMap = new Map() // Track basenames written across all configKey entries in this build to detect 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 75b0f4c476..02a6766654 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 @@ -4,6 +4,14 @@ import {fileExists, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' import {outputDebug} from '@shopify/cli-kit/node/output' import type {BuildContext} from '../../client-steps.js' +/** + * Resolves the output directory from an extension's outputPath. + * When outputPath is a file (has extension), uses dirname. Otherwise uses outputPath directly. + */ +export function resolveOutputDir(outputPath: string): string { + return extname(outputPath) ? dirname(outputPath) : outputPath +} + interface ConfigKeyManifestEntry { anchor?: string | undefined groupBy?: string | undefined @@ -121,12 +129,7 @@ export async function createOrUpdateManifestFile( context: BuildContext, entries: {[key: string]: unknown}, ): Promise { - const outputPath = context.extension.outputPath - /** - * Resolves the output directory from an extension's outputPath. - * When outputPath is a file (has extension), uses dirname. Otherwise uses outputPath directly. - */ - const outputDir = extname(outputPath) ? dirname(outputPath) : outputPath + const outputDir = resolveOutputDir(context.extension.outputPath) const manifestPath = joinPath(outputDir, 'manifest.json') diff --git a/packages/app/src/cli/services/dev/extension.test.ts b/packages/app/src/cli/services/dev/extension.test.ts index f63ce54346..f3f800d0b9 100644 --- a/packages/app/src/cli/services/dev/extension.test.ts +++ b/packages/app/src/cli/services/dev/extension.test.ts @@ -61,6 +61,7 @@ describe('devUIExtensions()', () => { expect(store.ExtensionsPayloadStore).toHaveBeenCalledWith( {mock: 'payload'}, {...options, websocketURL: 'wss://mock.url/extensions'}, + expect.any(Map), ) }) diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index 91eab7eb2b..109727a5cc 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -125,8 +125,9 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise ext.isPreviewable) const getExtensions = () => { 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 66ba105fe4..c0196f4972 100644 --- a/packages/app/src/cli/services/dev/extension/payload.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload.test.ts @@ -192,12 +192,12 @@ describe('getUIExtensionPayload', () => { assets: { tools: { name: 'tools', - url: 'http://tunnel-url.com/extensions/devUUID/assets/tools.json', + url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/tools.json', lastUpdated: expect.any(Number), }, instructions: { name: 'instructions', - url: 'http://tunnel-url.com/extensions/devUUID/assets/instructions.md', + url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/instructions.md', lastUpdated: expect.any(Number), }, }, @@ -241,12 +241,12 @@ describe('getUIExtensionPayload', () => { assets: { main: { name: 'main', - url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension.js', + url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/main.js', lastUpdated: expect.any(Number), }, should_render: { name: 'should_render', - url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension-conditions.js', + url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/should_render.js', lastUpdated: expect.any(Number), }, }, @@ -295,12 +295,12 @@ describe('getUIExtensionPayload', () => { assets: { main: { name: 'main', - url: 'http://tunnel-url.com/extensions/devUUID/assets/dist/test-ui-extension.js', + url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/main.js', lastUpdated: expect.any(Number), }, should_render: { name: 'should_render', - url: 'http://tunnel-url.com/extensions/devUUID/assets/dist/test-ui-extension-conditions.js', + url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/should_render.js', lastUpdated: expect.any(Number), }, }, @@ -309,6 +309,205 @@ describe('getUIExtensionPayload', () => { }) }) + test('emits a distinct URL per extension point even when built assets share a filepath', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const uiExtension = await testUIExtension({ + directory: tmpDir, + configuration: { + name: 'test-ui-extension', + type: 'ui_extension', + extension_points: [ + {target: 'TARGET_A', module: './src/ExtensionPointA.js'}, + {target: 'TARGET_B', module: './src/ExtensionPointB.js'}, + ], + }, + devUUID: 'devUUID', + }) + + await setupBuildOutput( + uiExtension, + tmpDir, + { + TARGET_A: {main: 'dist/main.js'}, + TARGET_B: {main: 'dist/main.js'}, + }, + {}, + ) + + const resolver = new Map() + const got = await getUIExtensionPayload( + uiExtension, + tmpDir, + { + ...createMockOptions(tmpDir, [uiExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, + }, + resolver, + ) + + expect(got.extensionPoints).toMatchObject([ + { + target: 'TARGET_A', + assets: { + main: {name: 'main', url: 'http://tunnel-url.com/extensions/devUUID/assets/TARGET_A/main.js'}, + }, + }, + { + target: 'TARGET_B', + assets: { + main: {name: 'main', url: 'http://tunnel-url.com/extensions/devUUID/assets/TARGET_B/main.js'}, + }, + }, + ]) + expect(resolver.get('TARGET_A/main.js')).toBe('dist/main.js') + expect(resolver.get('TARGET_B/main.js')).toBe('dist/main.js') + }) + }) + + test('emits a directory-prefix URL and per-file resolver entries when the config points at a folder', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const uiExtension = await testUIExtension({ + directory: tmpDir, + configuration: { + name: 'test-ui-extension', + type: 'ui_extension', + extension_points: [ + { + target: 'CUSTOM_EXTENSION_POINT', + module: './src/ExtensionPointA.js', + assets: './assets', + }, + ], + }, + devUUID: 'devUUID', + }) + + await setupBuildOutput( + uiExtension, + tmpDir, + {CUSTOM_EXTENSION_POINT: {assets: ['foo.json', 'subdir/bar.png']}}, + {'foo.json': '{}', 'subdir/bar.png': 'stub'}, + ) + + const extensionOutputPath = uiExtension.getOutputPathForDirectory(tmpDir) + const buildDirectory = extname(extensionOutputPath) ? dirname(extensionOutputPath) : extensionOutputPath + await writeFile(joinPath(buildDirectory, 'foo.json'), '{}') + await mkdir(joinPath(buildDirectory, 'subdir')) + await writeFile(joinPath(buildDirectory, 'subdir/bar.png'), 'stub') + + const resolver = new Map() + const got = await getUIExtensionPayload( + uiExtension, + tmpDir, + { + ...createMockOptions(tmpDir, [uiExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, + }, + resolver, + ) + + expect(got.extensionPoints).toMatchObject([ + { + target: 'CUSTOM_EXTENSION_POINT', + assets: { + assets: { + name: 'assets', + url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/assets/', + lastUpdated: expect.any(Number), + }, + }, + }, + ]) + expect(resolver.get('CUSTOM_EXTENSION_POINT/assets/foo.json')).toBe('foo.json') + expect(resolver.get('CUSTOM_EXTENSION_POINT/assets/subdir/bar.png')).toBe('subdir/bar.png') + }) + }) + + test('emits distinct directory URLs per extension point when two targets share the same assets folder', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const uiExtension = await testUIExtension({ + directory: tmpDir, + configuration: { + name: 'test-ui-extension', + type: 'ui_extension', + extension_points: [ + {target: 'TARGET_A', module: './src/ExtensionPointA.js', assets: './assets'}, + {target: 'TARGET_B', module: './src/ExtensionPointB.js', assets: './assets'}, + ], + }, + devUUID: 'devUUID', + }) + + await setupBuildOutput( + uiExtension, + tmpDir, + { + TARGET_A: {assets: ['foo.json']}, + TARGET_B: {assets: ['foo.json']}, + }, + {'foo.json': '{}'}, + ) + const extensionOutputPath = uiExtension.getOutputPathForDirectory(tmpDir) + const buildDirectory = extname(extensionOutputPath) ? dirname(extensionOutputPath) : extensionOutputPath + await writeFile(joinPath(buildDirectory, 'foo.json'), '{}') + + const resolver = new Map() + const got = await getUIExtensionPayload( + uiExtension, + tmpDir, + { + ...createMockOptions(tmpDir, [uiExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, + }, + resolver, + ) + + expect(got.extensionPoints).toMatchObject([ + { + target: 'TARGET_A', + assets: {assets: {url: 'http://tunnel-url.com/extensions/devUUID/assets/TARGET_A/assets/'}}, + }, + { + target: 'TARGET_B', + assets: {assets: {url: 'http://tunnel-url.com/extensions/devUUID/assets/TARGET_B/assets/'}}, + }, + ]) + // Both targets' resolver entries point at the same output-relative file. + expect(resolver.get('TARGET_A/assets/foo.json')).toBe('foo.json') + expect(resolver.get('TARGET_B/assets/foo.json')).toBe('foo.json') + }) + }) + + test('clears stale resolver entries on each payload regeneration', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const uiExtension = await testUIExtension({ + directory: tmpDir, + configuration: { + name: 'test-ui-extension', + type: 'ui_extension', + extension_points: [{target: 'CUSTOM_EXTENSION_POINT', module: './src/ExtensionPointA.js'}], + }, + devUUID: 'devUUID', + }) + + await setupBuildOutput(uiExtension, tmpDir, {CUSTOM_EXTENSION_POINT: {main: 'dist/main.js'}}, {}) + + const resolver = new Map([['STALE_TARGET/main.js', 'stale.js']]) + await getUIExtensionPayload( + uiExtension, + tmpDir, + { + ...createMockOptions(tmpDir, [uiExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, + }, + resolver, + ) + + expect(resolver.has('STALE_TARGET/main.js')).toBe(false) + expect(resolver.get('CUSTOM_EXTENSION_POINT/main.js')).toBe('dist/main.js') + }) + }) + test('maps intents from manifest.json to asset payloads', async () => { await inTemporaryDirectory(async (tmpDir) => { const uiExtension = await testUIExtension({ @@ -351,7 +550,7 @@ describe('getUIExtensionPayload', () => { action: 'create', schema: { name: 'schema', - url: 'http://tunnel-url.com/extensions/devUUID/assets/intents/create-schema.json', + url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/intents/0/schema.json', lastUpdated: expect.any(Number), }, }, @@ -360,7 +559,7 @@ describe('getUIExtensionPayload', () => { action: 'update', schema: { name: 'schema', - url: 'http://tunnel-url.com/extensions/devUUID/assets/intents/update-schema.json', + url: 'http://tunnel-url.com/extensions/devUUID/assets/CUSTOM_EXTENSION_POINT/intents/1/schema.json', lastUpdated: expect.any(Number), }, }, @@ -461,7 +660,7 @@ describe('getUIExtensionPayload', () => { assets: { tools: { name: 'tools', - url: 'http://tunnel-url.com/extensions/devUUID/assets/tools.json', + url: 'http://tunnel-url.com/extensions/devUUID/assets/admin.app.intent.link/tools.json', lastUpdated: expect.any(Number), }, }, diff --git a/packages/app/src/cli/services/dev/extension/payload.ts b/packages/app/src/cli/services/dev/extension/payload.ts index 6672aeb3e4..fd6cfface9 100644 --- a/packages/app/src/cli/services/dev/extension/payload.ts +++ b/packages/app/src/cli/services/dev/extension/payload.ts @@ -7,27 +7,50 @@ import {getUIExtensionRendererVersion} from '../../../models/app/app.js' import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {fileLastUpdatedTimestamp, readFile} from '@shopify/cli-kit/node/fs' import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' -import {dirname, joinPath} from '@shopify/cli-kit/node/path' +import {dirname, extname, joinPath} from '@shopify/cli-kit/node/path' export type GetUIExtensionPayloadOptions = Omit & { currentDevelopmentPayload?: Partial currentLocalizationPayload?: UIExtensionPayload['localization'] } -interface AssetMapperContext { - identifier: string +/** + * Per-extension map from an asset's URL subpath (relative to + * `/extensions//assets/`) to its output-relative filesystem path + * inside the extension's bundle directory. + * + * Populated during payload generation as URLs are emitted; consumed by the + * dev-server middleware to serve the right file when two extension points + * reference assets that share a basename (e.g. `../tools.json` and + * `./tools.json` both collapsed to `tools` by `uniqueBasename`). + */ +export type AssetResolver = Map + +/** + * Fields that stay constant across every asset mapping within one extension-point + * pass. Built once in `getExtensionPoints` and threaded into each mapper; the + * per-call parts (`identifier`, `manifestValue`) are passed as positional args. + */ +interface MappingContext { + target: string extensionPoint: DevNewExtensionPointSchema url: string extension: ExtensionInstance - manifestValue?: unknown + buildDirectory: string + resolver?: AssetResolver } export async function getUIExtensionPayload( extension: ExtensionInstance, bundlePath: string, options: GetUIExtensionPayloadOptions, + resolver?: AssetResolver, ): Promise { return useConcurrentOutputContext({outputPrefix: extension.outputPrefix}, async () => { + // Each payload regeneration is the source of truth for this extension's + // URL → filesystem mapping. Clear previous entries so stale targets or + // removed assets don't linger. + resolver?.clear() const extensionOutputPath = extension.getOutputPathForDirectory(bundlePath) const url = `${options.url}/extensions/${extension.devUUID}` const {localization, status: localizationStatus} = await getLocalization(extension, options) @@ -35,7 +58,7 @@ export async function getUIExtensionPayload( // If the extension has a custom output relative path, use that as the build directory // ex. ext/dist/handle.js -> ext/dist const buildDirectory = extension.outputRelativePath ? dirname(extensionOutputPath) : extensionOutputPath - const extensionPoints = await getExtensionPoints(extension, url, buildDirectory) + const extensionPoints = await getExtensionPoints(extension, url, buildDirectory, resolver) let metafields: {namespace: string; key: string}[] | null = null if ( @@ -108,7 +131,12 @@ export async function getUIExtensionPayload( }) } -async function getExtensionPoints(extension: ExtensionInstance, url: string, buildDirectory: string) { +async function getExtensionPoints( + extension: ExtensionInstance, + url: string, + buildDirectory: string, + resolver?: AssetResolver, +) { const config = extension.configuration as Record let extensionPoints = (config.extension_points ?? config.targeting) as DevNewExtensionPointSchema[] @@ -138,11 +166,9 @@ async function getExtensionPoints(extension: ExtensionInstance, url: string, bui return payload } - const payloadWithAssets = { - ...payload, - ...(await mapManifestAssetsToPayload(manifestEntry, extensionPoint, url, extension)), - } - return payloadWithAssets + const ctx: MappingContext = {target, extensionPoint, url, extension, buildDirectory, resolver} + const mappedResult = await mapManifestAssetsToPayload(manifestEntry, ctx) + return {...payload, ...mappedResult} }), ) } @@ -170,27 +196,40 @@ async function readBundleManifest( } /** - * Default asset mapper - reads the source path from the extension point config, - * falling back to build_manifest.assets for compiled assets like main and + * Default asset mapper - emits resolver-keyed URLs (`/`) + * and registers the manifest's output-relative path in the resolver so the dev + * server serves the right file per extension point. The raw config value is + * passed as sourcePath so `lastUpdated` reflects source edits. + * Falls back to build_manifest.assets for compiled assets like main and * should_render where the config field name doesn't match the asset identifier. */ -async function defaultAssetMapper({ - identifier, - extensionPoint, - url, - extension, -}: AssetMapperContext): Promise> { +async function defaultAssetMapper( + {target, extensionPoint, url, extension, resolver}: MappingContext, + identifier: string, + manifestValue: unknown, +): Promise> { + const urlSubpath = `${target}/${identifier}` // Dynamic key lookup — identifier can be "tools", "instructions", etc. - const filepath = extensionPoint[identifier as keyof typeof extensionPoint] - if (typeof filepath === 'string') { - const payload = await getAssetPayload(identifier, filepath, url, extension) + const rawFilepath = extensionPoint[identifier as keyof typeof extensionPoint] + if (typeof rawFilepath === 'string') { + const filepath = typeof manifestValue === 'string' ? manifestValue : rawFilepath + const sourcePath = typeof manifestValue === 'string' ? rawFilepath : undefined + const payload = await getAssetPayload(identifier, urlSubpath, filepath, url, extension, resolver, sourcePath) return {assets: {[payload.name]: payload}} } const buildManifest = extensionPoint.build_manifest const asset = buildManifest?.assets?.[identifier as keyof typeof buildManifest.assets] if (asset?.filepath) { - const payload = await getAssetPayload(identifier, asset.filepath, url, extension, asset.module) + const payload = await getAssetPayload( + identifier, + urlSubpath, + asset.filepath, + url, + extension, + resolver, + asset.module, + ) return {assets: {[payload.name]: payload}} } @@ -198,74 +237,105 @@ async function defaultAssetMapper({ } /** - * Intents asset mapper - iterates the extension point's intents array - * and resolves each intent's schema to an asset payload. + * Static assets mapper - handles directory-valued configs (e.g. `assets = "./assets"`). + * `include_assets` copies every file into the bundle and the manifest entry is + * an array of output-relative file paths. Emits one payload entry for the + * directory (URL prefix with trailing slash), registers a resolver entry per + * file so the middleware can serve individual fetches, and reports `lastUpdated` + * as the max mtime across the directory so in-place edits surface. + */ +async function staticAssetsMapper( + {target, url, buildDirectory, resolver}: MappingContext, + identifier: string, + files: string[], +): Promise> { + if (files.length === 0) return {} + const urlSubpath = `${target}/${identifier}` + for (const file of files) { + resolver?.set(`${urlSubpath}/${file}`, file) + } + const updatedTimestamps = await Promise.all( + files.map(async (file) => (await fileLastUpdatedTimestamp(joinPath(buildDirectory, file))) ?? 0), + ) + return { + assets: { + [identifier]: { + name: identifier, + url: `${url}/assets/${urlSubpath}/`, + lastUpdated: Math.max(...updatedTimestamps), + }, + }, + } +} + +/** + * Intents asset mapper - iterates the extension point's intents array and + * resolves each intent's schema to an asset payload. Each intent's URL is + * scoped by its index (`/intents//schema`) so two intents + * whose schema sources would share a basename still resolve correctly. */ -async function intentsAssetMapper({ - extensionPoint, - url, - extension, -}: AssetMapperContext): Promise> { +async function intentsAssetMapper( + {target, extensionPoint, url, extension, resolver}: MappingContext, + manifestIntents: {schema: string}[], +): Promise> { if (!extensionPoint.intents) return {} const intents = await Promise.all( - extensionPoint.intents.map(async (intent) => ({ - ...intent, - schema: await getAssetPayload('schema', intent.schema as string, url, extension), - })), + extensionPoint.intents.map(async (intent, index) => { + const rawSchema = intent.schema as string + const manifestSchema = manifestIntents[index]?.schema + const filepath = typeof manifestSchema === 'string' ? manifestSchema : rawSchema + const sourcePath = typeof manifestSchema === 'string' ? rawSchema : undefined + return { + ...intent, + schema: await getAssetPayload( + 'schema', + `${target}/intents/${index}/schema`, + filepath, + url, + extension, + resolver, + sourcePath, + ), + } + }), ) return {intents} } -type AssetMapper = (context: AssetMapperContext) => Promise> - /** * Mapper for compiled built assets (main, should_render). * Reads the filepath directly from manifest.json so the bundleFolder prefix is preserved. */ -async function builtAssetMapper({ - identifier, - manifestValue, - url, - extension, -}: AssetMapperContext): Promise> { - if (typeof manifestValue !== 'string') return {} - const payload = await getAssetPayload(identifier, manifestValue, url, extension) +async function builtAssetMapper( + {target, url, extension, resolver}: MappingContext, + identifier: string, + manifestValue: string, +): Promise> { + const payload = await getAssetPayload(identifier, `${target}/${identifier}`, manifestValue, url, extension, resolver) return {assets: {[payload.name]: payload}} } -/** - * Asset mappers registry - defines how each asset type should be handled. - * Assets not in this registry use the defaultAssetMapper. - */ -const ASSET_MAPPERS: {[key: string]: AssetMapper | undefined} = { - intents: intentsAssetMapper, - main: builtAssetMapper, - should_render: builtAssetMapper, -} - /** * Maps manifest entry to payload format. * Uses the manifest entry to know which assets exist for a target, * then reads source paths from the extension point config. + * Dispatches each identifier to the mapper whose expected `manifestValue` shape + * matches. Unknown identifiers (or known ones with mismatched shapes) fall + * through to `defaultAssetMapper`. */ async function mapManifestAssetsToPayload( manifestEntry: {[assetName: string]: unknown}, - extensionPoint: DevNewExtensionPointSchema, - url: string, - extension: ExtensionInstance, + ctx: MappingContext, ): Promise> { const mappingResults = await Promise.all( Object.keys(manifestEntry).map(async (identifier) => { - const context: AssetMapperContext = { - identifier, - extensionPoint, - url, - extension, - manifestValue: manifestEntry[identifier], - } - return ASSET_MAPPERS[identifier]?.(context) ?? defaultAssetMapper(context) + const value = manifestEntry[identifier] + if (isIntentsAsset(identifier, value)) return intentsAssetMapper(ctx, value) + if (isBuiltAsset(identifier, value)) return builtAssetMapper(ctx, identifier, value) + if (isStaticAsset(identifier, value)) return staticAssetsMapper(ctx, identifier, value) + return defaultAssetMapper(ctx, identifier, value) }), ) @@ -289,21 +359,56 @@ export function isNewExtensionPointsSchema(extensionPoints: unknown): extensionP /** * Builds an asset payload entry. * - * @param sourcePath - Optional source file path for the timestamp. When provided - * (e.g. for compiled assets), the URL uses `filepath` (the build output name) - * while `lastUpdated` is read from `sourcePath` (the source module). For static - * assets, `filepath` is used for both. + * The emitted URL is opaque — `/assets/` — and the actual + * filesystem path (`filepath`, relative to the extension's output directory) is + * recorded in `resolver` so the dev-server middleware can serve the right file + * even when two extension points reference sources whose basenames collide. + * + * @param name - The asset key as it appears in the payload (`main`, `tools`, + * `schema`, …). Included in the payload for consumers that key by name. + * @param urlSubpath - Target-scoped URL subpath. Typically `/`; + * `intents` map to `/intents//schema` to disambiguate array + * entries. + * @param filepath - Output-relative path inside the extension's bundle (what + * the middleware will ultimately read). + * @param sourcePath - Optional source file path for the timestamp. When + * provided (e.g. for compiled assets or static assets copied from outside + * the extension), `lastUpdated` reads the source file's mtime so edits there + * reflect in the payload. */ async function getAssetPayload( name: string, + urlSubpath: string, filepath: string, url: string, extension: ExtensionInstance, + resolver?: AssetResolver, sourcePath?: string, ) { + // Preserve the source file's extension in the URL so clients can infer the + // content type from the URL and the middleware's resolver key matches the + // emitted URL 1:1. + const urlSubpathWithExt = `${urlSubpath}${extname(filepath)}` + resolver?.set(urlSubpathWithExt, filepath) return { name, - url: `${url}${joinPath('/assets/', filepath)}`, + url: `${url}/assets/${urlSubpathWithExt}`, lastUpdated: (await fileLastUpdatedTimestamp(joinPath(extension.directory, sourcePath ?? filepath))) ?? 0, } } + +function isIntentsAsset(identifier: string, value: unknown): value is {schema: string}[] { + return identifier === 'intents' && Array.isArray(value) +} + +function isBuiltAsset(identifier: string, value: unknown): value is string { + return (identifier === 'main' || identifier === 'should_render') && typeof value === 'string' +} + +function isStaticAsset(identifier: string, value: unknown): value is string[] { + return ( + identifier === 'assets' && + Array.isArray(value) && + value.every((entry): entry is string => typeof entry === '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 fdc460d2d4..40d23e4800 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 @@ -414,10 +414,15 @@ describe('ExtensionsPayloadStore()', () => { await extensionsPayloadStore.updateExtension(updatedExtension, mockOptions, 'mock-bundle-path', {hidden: true}) // Then - expect(payload.getUIExtensionPayload).toHaveBeenCalledWith(updatedExtension, 'mock-bundle-path', { - ...mockOptions, - currentDevelopmentPayload: {hidden: true}, - }) + expect(payload.getUIExtensionPayload).toHaveBeenCalledWith( + updatedExtension, + 'mock-bundle-path', + { + ...mockOptions, + currentDevelopmentPayload: {hidden: true}, + }, + expect.any(Map), + ) expect(extensionsPayloadStore.getRawPayload()).toStrictEqual({ mock: 'payload', extensions: [{mock: 'getExtensionsPayloadResponse'}, {uuid: '456', foo: 'bar'}], @@ -441,12 +446,17 @@ describe('ExtensionsPayloadStore()', () => { await extensionsPayloadStore.updateExtension(updatedExtension, mockOptions, 'mock-bundle-path') // Then - expect(payload.getUIExtensionPayload).toHaveBeenCalledWith(updatedExtension, 'mock-bundle-path', { - ...mockOptions, - currentDevelopmentPayload: { - status: 'success', + expect(payload.getUIExtensionPayload).toHaveBeenCalledWith( + updatedExtension, + 'mock-bundle-path', + { + ...mockOptions, + currentDevelopmentPayload: { + status: 'success', + }, }, - }) + expect.any(Map), + ) expect(extensionsPayloadStore.getRawPayload()).toStrictEqual({ mock: 'payload', extensions: [{mock: 'getExtensionsPayloadResponse'}, {uuid: '456', development: {status: 'error'}}], @@ -472,13 +482,22 @@ describe('ExtensionsPayloadStore()', () => { await extensionsPayloadStore.updateExtension(updatedExtension, mockOptions, 'mock-bundle-path') // Then - expect(payload.getUIExtensionPayload).toHaveBeenCalledWith(updatedExtension, 'mock-bundle-path', { - ...mockOptions, - currentDevelopmentPayload: { - status: 'success', + expect(payload.getUIExtensionPayload).toHaveBeenCalledWith( + updatedExtension, + 'mock-bundle-path', + { + ...mockOptions, + currentDevelopmentPayload: { + status: 'success', + }, + currentLocalizationPayload: { + defaultLocale: 'en', + lastUpdated: 100, + translations: {en: {welcome: 'Welcome!'}}, + }, }, - currentLocalizationPayload: {defaultLocale: 'en', lastUpdated: 100, translations: {en: {welcome: 'Welcome!'}}}, - }) + expect.any(Map), + ) }) test('informs event listeners of the updated extension', async () => { @@ -525,6 +544,25 @@ describe('ExtensionsPayloadStore()', () => { }) }) + describe('deleteExtension()', () => { + test('removes the asset resolver entry for the deleted extension', () => { + // Given + const mockPayload = {extensions: [{uuid: '123'}, {uuid: '456'}]} as unknown as ExtensionsEndpointPayload + const assetResolvers = new Map([ + ['123', new Map([['CUSTOM_TARGET/tools', 'tools.json']])], + ['456', new Map([['CUSTOM_TARGET/tools', 'other.json']])], + ]) + const store = new ExtensionsPayloadStore(mockPayload, mockOptions, assetResolvers) + + // When + store.deleteExtension({devUUID: '123'} as unknown as ExtensionInstance) + + // Then + expect(store.getAssetResolver('123')).toBeUndefined() + expect(store.getAssetResolver('456')).toBeDefined() + }) + }) + describe('getAppAssets()', () => { test('returns asset directories when admin extension has static_root', () => { const adminExt = createAdminExtension({static_root: 'public'}) 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 fa0af94b97..93ce5169d8 100644 --- a/packages/app/src/cli/services/dev/extension/payload/store.ts +++ b/packages/app/src/cli/services/dev/extension/payload/store.ts @@ -1,6 +1,6 @@ import {UIExtensionPayload, ExtensionsEndpointPayload, DevNewExtensionPointSchema} from './models.js' import {ExtensionDevOptions} from '../../extension.js' -import {getUIExtensionPayload, isNewExtensionPointsSchema} from '../payload.js' +import {AssetResolver, 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' @@ -36,6 +36,7 @@ export enum ExtensionsPayloadStoreEvent { export async function getExtensionsPayloadStoreRawPayload( options: Omit, bundlePath: string, + resolvers?: Map, ): Promise { const payload: ExtensionsEndpointPayload = { app: { @@ -59,7 +60,9 @@ export async function getExtensionsPayloadStoreRawPayload( extensions: await Promise.all( options.extensions .filter((ext) => ext.isPreviewable) - .map((ext) => getUIExtensionPayload(ext, bundlePath, options)), + .map((ext) => + getUIExtensionPayload(ext, bundlePath, options, resolvers && getOrCreateResolver(resolvers, ext.devUUID)), + ), ), } @@ -81,15 +84,34 @@ export async function getExtensionsPayloadStoreRawPayload( return payload } +function getOrCreateResolver(resolvers: Map, devUUID: string): AssetResolver { + let resolver = resolvers.get(devUUID) + if (!resolver) { + resolver = new Map() + resolvers.set(devUUID, resolver) + } + return resolver +} + export class ExtensionsPayloadStore extends EventEmitter { private readonly options: ExtensionsPayloadStoreOptions private rawPayload: ExtensionsEndpointPayload private appAssetDirectories: Record | undefined - - constructor(rawPayload: ExtensionsEndpointPayload, options: ExtensionsPayloadStoreOptions) { + // Per-extension URL → output-relative filesystem path map, refreshed by + // `getUIExtensionPayload` on every build/rebuild. The dev server middleware + // consults this to serve the right file when asset basenames collide across + // extension points (`uniqueBasename` → `tools-1.json` etc.). + private readonly assetResolvers: Map + + constructor( + rawPayload: ExtensionsEndpointPayload, + options: ExtensionsPayloadStoreOptions, + assetResolvers: Map = new Map(), + ) { super() this.rawPayload = rawPayload this.options = options + this.assetResolvers = assetResolvers this.refreshAppAssetDirectories() } @@ -98,6 +120,10 @@ export class ExtensionsPayloadStore extends EventEmitter { return this.appAssetDirectories } + getAssetResolver(devUUID: string): AssetResolver | undefined { + return this.assetResolvers.get(devUUID) + } + getConnectedPayload() { const rawPayload = this.getRawPayload() return { @@ -192,11 +218,16 @@ export class ExtensionsPayloadStore extends EventEmitter { return } - payloadExtensions[index] = await getUIExtensionPayload(extension, bundlePath, { - ...this.options, - currentDevelopmentPayload: development ?? {status: payloadExtensions[index]?.development.status}, - currentLocalizationPayload: payloadExtensions[index]?.localization, - }) + payloadExtensions[index] = await getUIExtensionPayload( + extension, + bundlePath, + { + ...this.options, + currentDevelopmentPayload: development ?? {status: payloadExtensions[index]?.development.status}, + currentLocalizationPayload: payloadExtensions[index]?.localization, + }, + getOrCreateResolver(this.assetResolvers, extension.devUUID), + ) this.rawPayload.extensions = payloadExtensions @@ -207,12 +238,20 @@ export class ExtensionsPayloadStore extends EventEmitter { const index = this.rawPayload.extensions.findIndex((ext) => ext.uuid === extension.devUUID) if (index !== -1) { this.rawPayload.extensions.splice(index, 1) + this.assetResolvers.delete(extension.devUUID) this.emitUpdate([extension.devUUID]) } } async addExtension(extension: ExtensionInstance, bundlePath: string) { - this.rawPayload.extensions.push(await getUIExtensionPayload(extension, bundlePath, this.options)) + this.rawPayload.extensions.push( + await getUIExtensionPayload( + extension, + bundlePath, + this.options, + getOrCreateResolver(this.assetResolvers, extension.devUUID), + ), + ) this.emitUpdate([extension.devUUID]) } diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts index 9fc0ff5883..6349a687c4 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts @@ -14,6 +14,7 @@ import * as payload from '../payload.js' import {UIExtensionPayload} from '../payload/models.js' import {testUIExtension} from '../../../../models/app/app.test-data.js' import {AppEventWatcher} from '../../app-events/app-event-watcher.js' +import {copyConfigKeyEntry} from '../../../build/steps/include-assets/copy-config-key-entry.js' import {describe, expect, vi, test} from 'vitest' import {inTemporaryDirectory, mkdir, touchFile, writeFile} from '@shopify/cli-kit/node/fs' import * as h3 from 'h3' @@ -56,11 +57,20 @@ function getMockEvent({ return event as unknown as H3Event } -function getOptions({devOptions}: {devOptions: Partial}) { +function getOptions({ + devOptions, + assetResolvers, +}: { + devOptions: Partial + assetResolvers?: Map> +}) { const extensions = devOptions.extensions + const resolvers = assetResolvers ?? new Map() return { devOptions, - payloadStore: {}, + payloadStore: { + getAssetResolver: (devUUID: string) => resolvers.get(devUUID), + }, getExtensions: () => extensions, } as unknown as GetExtensionsMiddlewareOptions } @@ -101,8 +111,7 @@ describe('redirectToDevConsoleMiddleware()', () => { }) describe('fileServerMiddleware()', async () => { - // eslint-disable-next-line vitest/no-disabled-tests - test.skip('returns 404 if file does not exist', async () => { + test('returns 404 if file does not exist', async () => { await inTemporaryDirectory(async (tmpDir: string) => { vi.spyOn(utilities, 'sendError').mockImplementation(() => {}) @@ -238,7 +247,7 @@ describe('getExtensionAssetMiddleware()', () => { }) }) - test('returns static asset from extension source directory', async () => { + test('returns built asset from extension build output directory', async () => { await inTemporaryDirectory(async (tmpDir: string) => { const extension = await testUIExtension({directory: tmpDir}) @@ -248,9 +257,44 @@ describe('getExtensionAssetMiddleware()', () => { }, }) + // Create the built output file in dist/ (e.g. dist/handle.js) + const outputDir = joinPath(tmpDir, 'dist') + await mkdir(outputDir) + const outputFile = joinPath(outputDir, extension.outputFileName) + await touchFile(outputFile) + await writeFile(outputFile, 'compiled bundle content') + + const event = getMockEvent({ + params: { + extensionId: extension.devUUID, + assetPath: extension.outputFileName, + }, + }) + + const result = await getExtensionAssetMiddleware(options)(event) + + expect(event.node.res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/javascript') + expect(String(result)).toBe('compiled bundle content') + }) + }) + + test('returns static asset that include_assets copied into the output directory', async () => { + // Simulates admin_link/ui_extension: include_assets copies `targeting[].tools` + // (possibly from outside the extension directory) into outputDir via uniqueBasename. + // The dev server serves whatever lives there, keyed by the manifest's output-relative name. + await inTemporaryDirectory(async (tmpDir: string) => { + const extension = await testUIExtension({directory: tmpDir}) + + const options = getOptions({ + devOptions: { + extensions: [extension], + }, + }) + + const outputDir = joinPath(tmpDir, 'dist') + await mkdir(outputDir) const fileName = 'tools.json' - await touchFile(joinPath(tmpDir, fileName)) - await writeFile(joinPath(tmpDir, fileName), '{"tools": []}') + await writeFile(joinPath(outputDir, fileName), '{"tools": []}') const event = getMockEvent({ params: { @@ -266,8 +310,9 @@ describe('getExtensionAssetMiddleware()', () => { }) }) - test('returns built asset from extension build output directory', async () => { + test('returns 404 when the requested file is not present in the output directory', async () => { await inTemporaryDirectory(async (tmpDir: string) => { + vi.spyOn(utilities, 'sendError').mockImplementation(() => {}) const extension = await testUIExtension({directory: tmpDir}) const options = getOptions({ @@ -276,29 +321,25 @@ describe('getExtensionAssetMiddleware()', () => { }, }) - // Create the built output file in dist/ (e.g. dist/handle.js) const outputDir = joinPath(tmpDir, 'dist') await mkdir(outputDir) - const outputFile = joinPath(outputDir, extension.outputFileName) - await touchFile(outputFile) - await writeFile(outputFile, 'compiled bundle content') const event = getMockEvent({ params: { extensionId: extension.devUUID, - assetPath: extension.outputFileName, + assetPath: 'never-configured.json', }, }) - const result = await getExtensionAssetMiddleware(options)(event) + await getExtensionAssetMiddleware(options)(event) - expect(event.node.res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/javascript') - expect(String(result)).toBe('compiled bundle content') + expect(utilities.sendError).toHaveBeenCalledWith(event, expect.objectContaining({statusCode: 404})) }) }) - test('serves built asset over source file when both exist', async () => { + test('returns 404 for path traversal attempts', async () => { await inTemporaryDirectory(async (tmpDir: string) => { + vi.spyOn(utilities, 'sendError').mockImplementation(() => {}) const extension = await testUIExtension({directory: tmpDir}) const options = getOptions({ @@ -307,24 +348,182 @@ describe('getExtensionAssetMiddleware()', () => { }, }) - // Create both a source file and a built file with the same name - const fileName = extension.outputFileName - await writeFile(joinPath(tmpDir, fileName), 'source content') - const outputDir = joinPath(tmpDir, 'dist') - await mkdir(outputDir) - await writeFile(joinPath(outputDir, fileName), 'built content') + // A file outside outputDir that we want to ensure can't be reached. + await writeFile(joinPath(tmpDir, 'secret.txt'), 'secret') const event = getMockEvent({ params: { extensionId: extension.devUUID, - assetPath: fileName, + assetPath: '../secret.txt', }, }) - const result = await getExtensionAssetMiddleware(options)(event) + await getExtensionAssetMiddleware(options)(event) + + expect(utilities.sendError).toHaveBeenCalledWith(event, { + statusCode: 404, + statusMessage: 'Not Found', + }) + }) + }) + + test('serves a ../tools.json source after include_assets flattens it into the output directory', async () => { + // End-to-end verification of the motivating scenario: a TOML config declares + // `tools = "../tools.json"` (a path outside the extension directory). + // 1. include_assets (via copyConfigKeyEntry) copies the outside file into + // outputDir under its flat basename. + // 2. The payload emits an opaque /tools URL and registers a + // resolver entry mapping that URL to the flattened filename. + // 3. The dev server middleware resolves the request against the resolver + // and serves the correct file. + await inTemporaryDirectory(async (tmpDir: string) => { + const extDir = joinPath(tmpDir, 'ext') + await mkdir(extDir) + + // Source file lives OUTSIDE the extension directory — addressable from + // the extension as `../tools.json`. + const toolsContent = '{"tools":["outside-source"]}' + await writeFile(joinPath(tmpDir, 'tools.json'), toolsContent) + + // product_subscription's outputRelativePath is `dist/${handle}.js`, so + // outputDir resolves to `/dist`. + const extension = await testUIExtension({ + directory: extDir, + configuration: { + name: 'test-ext', + type: 'product_subscription', + handle: 'test-ext', + metafields: [], + extension_points: [{target: 'target1', tools: '../tools.json'}], + } as any, + }) + const outputDir = joinPath(extDir, 'dist') + await mkdir(outputDir) + + // Simulate the real include_assets step running against the config. + const buildResult = await copyConfigKeyEntry({ + key: 'extension_points[].tools', + baseDir: extDir, + outputDir, + context: {extension, options: {stdout: {write: vi.fn()}}} as any, + }) + + const flattened = buildResult.pathMap.get('../tools.json') as string + expect(flattened).toBe('tools.json') + + // Simulate what payload generation would register for this extension. + const resolvers = new Map>() + resolvers.set(extension.devUUID, new Map([[`target1/tools`, flattened]])) + const options = getOptions({devOptions: {extensions: [extension]}, assetResolvers: resolvers}) + + // Request the opaque URL the payload would have emitted. + const servedEvent = getMockEvent({ + params: {extensionId: extension.devUUID, assetPath: 'target1/tools'}, + }) + const served = await getExtensionAssetMiddleware(options)(servedEvent) + expect(served).toBe(toolsContent) + + // The raw "../tools.json" is NOT reachable — outside the outputDir sandbox. + vi.spyOn(utilities, 'sendError').mockImplementation(() => {}) + const traversalEvent = getMockEvent({ + params: {extensionId: extension.devUUID, assetPath: '../tools.json'}, + }) + await getExtensionAssetMiddleware(options)(traversalEvent) + expect(utilities.sendError).toHaveBeenCalledWith(traversalEvent, { + statusCode: 404, + statusMessage: 'Not Found', + }) + }) + }) + + test('serves distinct files for two targets whose source basenames collide', async () => { + // The motivating regression for the resolver: two extension points both + // declare a `tools.json` source (one at `../tools.json`, one at + // `./tools.json`). include_assets disambiguates on disk via uniqueBasename + // (`tools.json` + `tools-1.json`), and the resolver maps each target's + // opaque URL to its own file. Requests against `/tools` serve + // distinct content per target even though the URL shape is uniform. + await inTemporaryDirectory(async (tmpDir: string) => { + const extension = await testUIExtension({directory: tmpDir}) + const outputDir = joinPath(tmpDir, 'dist') + await mkdir(outputDir) + await writeFile(joinPath(outputDir, 'tools.json'), '{"source":"outside"}') + await writeFile(joinPath(outputDir, 'tools-1.json'), '{"source":"inside"}') + + const resolvers = new Map>() + resolvers.set( + extension.devUUID, + new Map([ + ['target-a/tools', 'tools.json'], + ['target-b/tools', 'tools-1.json'], + ]), + ) + const options = getOptions({devOptions: {extensions: [extension]}, assetResolvers: resolvers}) + + const eventA = getMockEvent({params: {extensionId: extension.devUUID, assetPath: 'target-a/tools'}}) + const eventB = getMockEvent({params: {extensionId: extension.devUUID, assetPath: 'target-b/tools'}}) + + const servedA = await getExtensionAssetMiddleware(options)(eventA) + const servedB = await getExtensionAssetMiddleware(options)(eventB) + + expect(servedA).toBe('{"source":"outside"}') + expect(servedB).toBe('{"source":"inside"}') + }) + }) + + test('serves files from a directory-valued config via the resolver, including nested subdirectories', async () => { + // Covers `assets = "./assets"` — include_assets copies each file into the + // bundle, the payload emits a directory-prefix URL, and the resolver has + // one entry per file so the middleware serves individual fetches. + await inTemporaryDirectory(async (tmpDir: string) => { + const extension = await testUIExtension({directory: tmpDir}) + const outputDir = joinPath(tmpDir, 'dist') + await mkdir(joinPath(outputDir, 'subdir')) + await writeFile(joinPath(outputDir, 'foo.json'), '{"ok":true}') + await writeFile(joinPath(outputDir, 'subdir/bar.png'), 'nested') + + const resolvers = new Map>() + resolvers.set( + extension.devUUID, + new Map([ + ['TARGET/assets/foo.json', 'foo.json'], + ['TARGET/assets/subdir/bar.png', 'subdir/bar.png'], + ]), + ) + const options = getOptions({devOptions: {extensions: [extension]}, assetResolvers: resolvers}) + + const rootFile = await getExtensionAssetMiddleware(options)( + getMockEvent({params: {extensionId: extension.devUUID, assetPath: 'TARGET/assets/foo.json'}}), + ) + const nestedFile = await getExtensionAssetMiddleware(options)( + getMockEvent({params: {extensionId: extension.devUUID, assetPath: 'TARGET/assets/subdir/bar.png'}}), + ) + + expect(rootFile).toBe('{"ok":true}') + expect(nestedFile).toBe('nested') + }) + }) + + test('returns 404 for a resolver-mapped path that escapes the output directory', async () => { + // A defensive check: even if the resolver somehow produced a malicious + // value (traversal string, absolute path), the traversal guard still + // blocks it before any file read. + await inTemporaryDirectory(async (tmpDir: string) => { + vi.spyOn(utilities, 'sendError').mockImplementation(() => {}) + const extension = await testUIExtension({directory: tmpDir}) + await writeFile(joinPath(tmpDir, 'secret.txt'), 'secret') - // Built asset takes priority - expect(result).toBe('built content') + const resolvers = new Map>() + resolvers.set(extension.devUUID, new Map([['evil/tools', '../secret.txt']])) + const options = getOptions({devOptions: {extensions: [extension]}, assetResolvers: resolvers}) + + const event = getMockEvent({params: {extensionId: extension.devUUID, assetPath: 'evil/tools'}}) + await getExtensionAssetMiddleware(options)(event) + + expect(utilities.sendError).toHaveBeenCalledWith(event, { + statusCode: 404, + statusMessage: 'Not Found', + }) }) }) }) 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 3ba750872a..86fd92617c 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.ts @@ -3,9 +3,10 @@ import {GetExtensionsMiddlewareOptions} from './models.js' import {getUIExtensionPayload} from '../payload.js' import {getHTML} from '../templates.js' import {getWebSocketUrl} from '../../extension.js' +import {resolveOutputDir} from '../../../build/steps/include-assets/generate-manifest.js' import {fileExists, isDirectory, readFile, findPathUp} from '@shopify/cli-kit/node/fs' import {sendRedirect, defineEventHandler, getRequestHeader, getRouterParams, setResponseHeader} from 'h3' -import {joinPath, resolvePath, dirname, extname, moduleDirectory} from '@shopify/cli-kit/node/path' +import {joinPath, resolvePath, relativePath, isAbsolutePath, extname, moduleDirectory} from '@shopify/cli-kit/node/path' import {outputDebug} from '@shopify/cli-kit/node/output' import type {H3Event} from 'h3' @@ -31,14 +32,15 @@ export const redirectToDevConsoleMiddleware = defineEventHandler(async (event) = export async function fileServerMiddleware(event: H3Event, options: {filePath: string}) { let {filePath} = options - if (await isDirectory(filePath)) { - filePath += filePath.endsWith('/') ? `index.html` : '/index.html' + if (!(await fileExists(filePath))) { + return sendError(event, {statusCode: 404, statusMessage: `Not Found: ${filePath}`}) } - const exists = await fileExists(filePath) - - if (!exists) { - return sendError(event, {statusCode: 404, statusMessage: `Not Found: ${filePath}`}) + if (await isDirectory(filePath)) { + filePath += filePath.endsWith('/') ? `index.html` : '/index.html' + if (!(await fileExists(filePath))) { + return sendError(event, {statusCode: 404, statusMessage: `Not Found: ${filePath}`}) + } } // Pass `{}` to opt out of cli-kit's `{encoding: 'utf8'}` default — binary @@ -70,7 +72,7 @@ export async function fileServerMiddleware(event: H3Event, options: {filePath: s return fileContent } -export function getExtensionAssetMiddleware({getExtensions}: GetExtensionsMiddlewareOptions) { +export function getExtensionAssetMiddleware({getExtensions, payloadStore}: GetExtensionsMiddlewareOptions) { return defineEventHandler(async (event) => { const {extensionId, assetPath = ''} = getRouterParams(event) const extension = getExtensions().find((ext) => ext.devUUID === extensionId) @@ -82,23 +84,26 @@ export function getExtensionAssetMiddleware({getExtensions}: GetExtensionsMiddle }) } - const resolvedExtensionDirectory = resolvePath(extension.directory) - const builtAssetPath = joinPath( - dirname(joinPath(resolvedExtensionDirectory, extension.outputRelativePath)), - assetPath, - ) - - // Try the build output directory first (for compiled assets like dist/handle.js), - // then fall back to the extension's source directory (for static assets like tools, instructions). - const filePath = (await fileExists(builtAssetPath)) - ? builtAssetPath - : joinPath(resolvedExtensionDirectory, assetPath) - - if (!filePath.startsWith(resolvedExtensionDirectory)) { - return sendError(event, {statusCode: 403, statusMessage: 'Path traversal is not allowed'}) + // Serve from the extension's bundle directory. The include_assets build step + // copies every configured static asset here, so the filesystem under + // outputDir is the effective allowlist for this route. + // + // URLs emitted by the payload are opaque (`/`) and the + // resolver maps each to the actual (possibly uniqueBasename-renamed) file. + // Requests without a resolver entry fall through to direct outputDir serving + // — covers uncommon direct fetches of compiled artefacts by filename. + const resolver = payloadStore.getAssetResolver(extension.devUUID) + const filesystemPath = resolver?.get(assetPath) ?? assetPath + + const resolvedOutputDir = resolvePath(resolveOutputDir(extension.outputPath)) + const candidate = resolvePath(joinPath(resolvedOutputDir, filesystemPath)) + const rel = relativePath(resolvedOutputDir, candidate) + + if (rel.startsWith('..') || isAbsolutePath(rel)) { + return sendError(event, {statusCode: 404, statusMessage: 'Not Found'}) } - return fileServerMiddleware(event, {filePath}) + return fileServerMiddleware(event, {filePath: candidate}) }) }