Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/serve-ui-extension-source-maps-in-dev.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': patch
---

Serve UI extension source maps during `app dev`
65 changes: 65 additions & 0 deletions packages/app/src/cli/services/dev/extension/payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,71 @@ describe('getUIExtensionPayload', () => {
})
})

test("registers the built asset's source map under the URL its sourceMappingURL resolves to", 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',
})

// The map lives in the extension directory, not the bundle: `keepBuiltSourcemapsLocally`
// moves it there so `.js.map` files stay out of deploy bundles.
await setupBuildOutput(
uiExtension,
tmpDir,
{CUSTOM_EXTENSION_POINT: {main: 'dist/test-ui-extension.js'}},
{'dist/test-ui-extension.js.map': '{"version":3}'},
)

const resolver = new Map<string, string>()
await getUIExtensionPayload(
uiExtension,
tmpDir,
{
...createMockOptions(tmpDir, [uiExtension]),
currentDevelopmentPayload: {hidden: true, status: 'success'},
},
resolver,
)

expect(resolver.get('CUSTOM_EXTENSION_POINT/test-ui-extension.js.map')).toBe('dist/test-ui-extension.js.map')
})
})

test('does not register a source map when the built asset has none', 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/test-ui-extension.js'}}, {})

const resolver = new Map<string, string>()
await getUIExtensionPayload(
uiExtension,
tmpDir,
{
...createMockOptions(tmpDir, [uiExtension]),
currentDevelopmentPayload: {hidden: true, status: 'success'},
},
resolver,
)

expect(resolver.has('CUSTOM_EXTENSION_POINT/test-ui-extension.js.map')).toBe(false)
})
})

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({
Expand Down
42 changes: 40 additions & 2 deletions packages/app/src/cli/services/dev/extension/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import {ExtensionsPayloadStoreOptions} from './payload/store.js'
import {getUIExtensionResourceURL} from '../../../utilities/extensions/configuration.js'
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 {fileExists, fileLastUpdatedTimestamp, readFile} from '@shopify/cli-kit/node/fs'
import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components'
import {dirname, extname, joinPath} from '@shopify/cli-kit/node/path'
import {basename, dirname, extname, joinPath} from '@shopify/cli-kit/node/path'

export type GetUIExtensionPayloadOptions = Omit<ExtensionsPayloadStoreOptions, 'appWatcher'> & {
currentDevelopmentPayload?: Partial<UIExtensionPayload['development']>
Expand All @@ -23,9 +23,22 @@ export type GetUIExtensionPayloadOptions = Omit<ExtensionsPayloadStoreOptions, '
* 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`).
*
* Source-map entries (`.js.map`) are the exception: they are relative to the
* extension's own directory, because maps are kept out of the bundle. See
* `registerSourceMap`.
*/
export type AssetResolver = Map<string, string>

/**
* Suffix of the source maps esbuild writes next to a built asset (`<handle>.js.map`).
*
* Deploy bundles exclude every `.js.map` file unconditionally (`BUNDLE_EXCLUSION_PATTERNS`), so
* the suffix unambiguously identifies a resolver entry that lives in the extension directory
* rather than in the bundle.
*/
export const SOURCE_MAP_SUFFIX = '.js.map'

/**
* Fields that stay constant across every asset mapping within one extension-point
* pass. Built once in `getExtensionPoints` and threaded into each mapper; the
Expand Down Expand Up @@ -313,9 +326,34 @@ async function builtAssetMapper(
manifestValue: string,
): Promise<Partial<DevNewExtensionPointSchema>> {
const payload = await getAssetPayload(identifier, `${target}/${identifier}`, manifestValue, url, extension, resolver)
await registerSourceMap(target, manifestValue, extension, resolver)
return {assets: {[payload.name]: payload}}
}

/**
* Registers the built asset's source map with the resolver, when one was generated.
*
* esbuild writes a `sourceMappingURL` relative to the built file, so the browser requests
* the map next to the asset's own URL — `<target>/<basename>.js.map` — a key no asset
* payload emits. The map itself is never in the bundle (`keepBuiltSourcemapsLocally` moves
* it into the extension directory to keep `.js.map` files out of deploy bundles), so the
* registered value is resolved against the extension directory by the dev server.
*/
async function registerSourceMap(
target: string,
filepath: string,
extension: ExtensionInstance,
resolver?: AssetResolver,
): Promise<void> {
if (!resolver) return

const sourceMapPath = `${filepath}.map`
if (!sourceMapPath.endsWith(SOURCE_MAP_SUFFIX)) return
if (!(await fileExists(joinPath(extension.directory, sourceMapPath)))) return

resolver.set(`${target}/${basename(sourceMapPath)}`, sourceMapPath)
}

/**
* Maps manifest entry to payload format.
* Uses the manifest entry to know which assets exist for a target,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,67 @@ describe('getExtensionAssetMiddleware()', () => {
})
})

test('serves a registered source map from the extension directory, not the bundle', async () => {
// Source maps never reach the bundle — `keepBuiltSourcemapsLocally` moves them into the
// extension directory so they stay out of deploy bundles. The bundle dir is left without
// a map here on purpose, so this also pins that the bundle is not the source.
await inTemporaryDirectory(async (tmpDir: string) => {
const extension = await testUIExtension({directory: tmpDir})

const sourceMapContent = '{"version":3,"sources":["src/index.tsx"]}'
const localBuildDir = joinPath(tmpDir, 'dist')
await mkdir(localBuildDir)
await writeFile(joinPath(localBuildDir, `${extension.outputFileName}.map`), sourceMapContent)

const resolvers = new Map<string, Map<string, string>>()
resolvers.set(
extension.devUUID,
new Map([[`target1/${extension.outputFileName}.map`, `dist/${extension.outputFileName}.map`]]),
)
const options = getOptions({devOptions: {extensions: [extension]}, assetResolvers: resolvers})

const event = getMockEvent({
params: {
extensionId: extension.devUUID,
assetPath: `target1/${extension.outputFileName}.map`,
},
})

const result = await getExtensionAssetMiddleware(options)(event)

expect(event.node.res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json')
expect(String(result)).toBe(sourceMapContent)
})
})

test('returns 404 for a source map that the payload did not register', async () => {
await inTemporaryDirectory(async (tmpDir: string) => {
vi.spyOn(utilities, 'sendError').mockImplementation(() => {})
const extension = await testUIExtension({directory: tmpDir})

const options = getOptions({devOptions: {extensions: [extension]}})

// Readable on disk, but unreachable because no resolver entry points at it.
const localBuildDir = joinPath(tmpDir, 'dist')
await mkdir(localBuildDir)
await writeFile(joinPath(localBuildDir, 'secrets.js.map'), 'unregistered')

const event = getMockEvent({
params: {
extensionId: extension.devUUID,
assetPath: 'dist/secrets.js.map',
},
})

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).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {getExtensionPointRedirectUrl, getExtensionUrl, getRedirectUrl, sendError} from './utilities.js'
import {GetExtensionsMiddlewareOptions} from './models.js'
import {getUIExtensionPayload} from '../payload.js'
import {getUIExtensionPayload, SOURCE_MAP_SUFFIX} from '../payload.js'
import {getHTML} from '../templates.js'
import {getWebSocketUrl} from '../../extension.js'
import {resolveOutputDir} from '../../../build/steps/include-assets/generate-manifest.js'
Expand Down Expand Up @@ -53,6 +53,7 @@ export async function fileServerMiddleware(event: H3Event, options: {filePath: s
'.html': 'text/html',
'.js': 'text/javascript',
'.json': 'application/json',
'.map': 'application/json',
'.wasm': 'application/wasm',
'.css': 'text/css',
'.png': 'image/png',
Expand Down Expand Up @@ -93,12 +94,23 @@ export function getExtensionAssetMiddleware({getExtensions, payloadStore}: GetEx
// 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 resolvedPath = resolver?.get(assetPath)
const filesystemPath = resolvedPath ?? assetPath

// Source maps are the one asset that is deliberately absent from the bundle:
// `keepBuiltSourcemapsLocally` moves them back into the extension directory so they
// never reach a deploy bundle. Serve those from the extension directory instead, and
// only when the payload registered them, so the resolver stays the allowlist and the
// extension directory isn't otherwise readable through this route.
const isSourceMap = filesystemPath.endsWith(SOURCE_MAP_SUFFIX)
if (isSourceMap && resolvedPath === undefined) {
return sendError(event, {statusCode: 404, statusMessage: 'Not Found'})
}

const resolvedOutputDir = resolvePath(resolveOutputDir(extension.outputPath))
const candidate = resolvePath(joinPath(resolvedOutputDir, filesystemPath))
const rootDirectory = resolvePath(isSourceMap ? extension.directory : resolveOutputDir(extension.outputPath))
const candidate = resolvePath(joinPath(rootDirectory, filesystemPath))

if (!isSubpath(resolvedOutputDir, candidate)) {
if (!isSubpath(rootDirectory, candidate)) {
return sendError(event, {statusCode: 404, statusMessage: 'Not Found'})
}

Expand Down
Loading