From c45177235c75ebcb20b1b9a47948595a2f6e56ce Mon Sep 17 00:00:00 2001 From: Trish Ta Date: Thu, 30 Jul 2026 09:06:23 -0400 Subject: [PATCH] Serve UI extension source maps during app dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The built bundle ends with a relative `//# sourceMappingURL=.js.map`, so the browser requests the map next to the asset's own URL — `/assets//.js.map` — a key no asset payload emits. The request 404'd and DevTools only ever showed minified output. Register that key on the asset resolver when the map exists, and serve `.js.map` requests from the extension directory. Maps are deliberately absent from the bundle: `keepBuiltSourcemapsLocally` moves them back into the extension directory so `.js.map` files never reach a deploy bundle. No build step changes, so that invariant is untouched. Unregistered `.js.map` paths 404, keeping the resolver the allowlist for this route. Assisted-By: devx/5a367c53-dfe0-42d0-8b9f-5258f54d589a --- .../serve-ui-extension-source-maps-in-dev.md | 5 ++ .../services/dev/extension/payload.test.ts | 65 +++++++++++++++++++ .../src/cli/services/dev/extension/payload.ts | 42 +++++++++++- .../dev/extension/server/middlewares.test.ts | 61 +++++++++++++++++ .../dev/extension/server/middlewares.ts | 22 +++++-- 5 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 .changeset/serve-ui-extension-source-maps-in-dev.md diff --git a/.changeset/serve-ui-extension-source-maps-in-dev.md b/.changeset/serve-ui-extension-source-maps-in-dev.md new file mode 100644 index 00000000000..111c1e74361 --- /dev/null +++ b/.changeset/serve-ui-extension-source-maps-in-dev.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Serve UI extension source maps during `app dev` 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 615ef9519e7..06f0cea1f96 100644 --- a/packages/app/src/cli/services/dev/extension/payload.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload.test.ts @@ -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() + 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() + 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({ diff --git a/packages/app/src/cli/services/dev/extension/payload.ts b/packages/app/src/cli/services/dev/extension/payload.ts index 8ab00343c43..e09ebb7a7d3 100644 --- a/packages/app/src/cli/services/dev/extension/payload.ts +++ b/packages/app/src/cli/services/dev/extension/payload.ts @@ -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 & { currentDevelopmentPayload?: Partial @@ -23,9 +23,22 @@ export type GetUIExtensionPayloadOptions = Omit +/** + * Suffix of the source maps esbuild writes next to a built asset (`.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 @@ -313,9 +326,34 @@ async function builtAssetMapper( manifestValue: string, ): Promise> { 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 — `/.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 { + 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, 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 88b8fffb033..4ba528f2eda 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 @@ -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>() + 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). 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 3c3c553553b..5477c00c9a4 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.ts @@ -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' @@ -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', @@ -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'}) }