From 3424286e81de3eeb4e5b6709740d128a22798777 Mon Sep 17 00:00:00 2001 From: Alfonso Noriega Date: Fri, 29 May 2026 11:38:04 +0200 Subject: [PATCH] Fix theme dev storefront host for preview stores --- packages/theme/src/cli/commands/theme/dev.ts | 12 +++ packages/theme/src/cli/services/dev.test.ts | 98 ++++++++++++++++++- packages/theme/src/cli/services/dev.ts | 23 ++++- .../dev-server-session.test.ts | 27 +++++ .../theme-environment/dev-server-session.ts | 15 +-- .../cli/utilities/theme-environment/proxy.ts | 6 +- .../storefront-renderer.test.ts | 21 ++++ .../theme-environment/storefront-renderer.ts | 10 +- .../theme-environment/theme-environment.ts | 7 +- .../cli/utilities/theme-environment/types.ts | 6 ++ 10 files changed, 208 insertions(+), 17 deletions(-) diff --git a/packages/theme/src/cli/commands/theme/dev.ts b/packages/theme/src/cli/commands/theme/dev.ts index c749ed07b60..a2cd3693236 100644 --- a/packages/theme/src/cli/commands/theme/dev.ts +++ b/packages/theme/src/cli/commands/theme/dev.ts @@ -77,6 +77,16 @@ You can run this command only in a directory that matches the [default Shopify t description: 'Synchronize Theme Editor updates in the local theme files.', env: 'SHOPIFY_FLAG_THEME_EDITOR_SYNC', }), + 'storefront-host': Flags.string({ + hidden: true, + description: 'Storefront host used by the local development server when it differs from --store.', + env: 'SHOPIFY_FLAG_STOREFRONT_HOST', + }), + 'preview-url': Flags.string({ + hidden: true, + description: 'Preview URL to show for the share preview shortcut when the standard preview_theme_id URL is not usable.', + env: 'SHOPIFY_FLAG_PREVIEW_URL', + }), port: Flags.string({ description: 'Local port to serve theme preview from.', env: 'SHOPIFY_FLAG_PORT', @@ -169,6 +179,8 @@ You can run this command only in a directory that matches the [default Shopify t commandConfig: this.config, directory: flags.path, store: flags.store, + storefrontHost: flags['storefront-host'], + previewUrl: flags['preview-url'], password: flags.password, storePassword: flags['store-password'], theme, diff --git a/packages/theme/src/cli/services/dev.test.ts b/packages/theme/src/cli/services/dev.test.ts index 2a79bf50f16..16dd20daf41 100644 --- a/packages/theme/src/cli/services/dev.test.ts +++ b/packages/theme/src/cli/services/dev.test.ts @@ -1,4 +1,12 @@ -import {dev, openURLSafely, renderLinks, createKeypressHandler, reportDevAnalytics} from './dev.js' +import { + dev, + openURLSafely, + renderLinks, + createKeypressHandler, + reportDevAnalytics, + storefrontFqdnForThemeDev, + themeEditorUrlForThemeDev, +} from './dev.js' import {setupDevServer} from '../utilities/theme-environment/theme-environment.js' import {hasRequiredThemeDirectories} from '../utilities/theme-fs.js' import {isStorefrontPasswordProtected} from '../utilities/theme-environment/storefront-session.js' @@ -24,6 +32,14 @@ vi.mock('@shopify/cli-kit/node/colors', () => ({ vi.mock('@shopify/cli-kit/node/system', () => ({ openURL: vi.fn(), })) +vi.mock('@shopify/cli-kit/node/context/fqdn', () => ({ + storeAdminUrl: (storeFqdn: string) => { + if (storeFqdn.endsWith('.my.shop.dev')) { + return `admin.shop.dev/store/${storeFqdn.replace('.my.shop.dev', '')}` + } + return storeFqdn + }, +})) vi.mock('@shopify/cli-kit/node/analytics', () => ({ reportAnalyticsEvent: vi.fn(), })) @@ -55,6 +71,34 @@ vi.mock('../utilities/theme-environment/dev-server-session.js', () => ({ const store = 'my-store.myshopify.com' const theme = buildTheme({id: 123, name: 'My Theme', role: DEVELOPMENT_THEME_ROLE})! +describe('storefrontFqdnForThemeDev', () => { + test('defaults to the store host', () => { + expect(storefrontFqdnForThemeDev('preview-1780041822.dev-api.shop.dev')).toBe( + 'preview-1780041822.dev-api.shop.dev', + ) + }) + + test('uses an explicit storefront host override when provided', () => { + expect(storefrontFqdnForThemeDev('preview-1780041822.dev-api.shop.dev', 'preview-1780041822.my.shop.dev')).toBe( + 'preview-1780041822.my.shop.dev', + ) + }) +}) + +describe('themeEditorUrlForThemeDev', () => { + test('uses standard theme editor URLs for regular stores', () => { + expect(themeEditorUrlForThemeDev('my-store.myshopify.com', 123, '9292')).toBe( + 'https://my-store.myshopify.com/admin/themes/123/editor?hr=9292', + ) + }) + + test('uses local admin web URLs for dev-api store hosts', () => { + expect(themeEditorUrlForThemeDev('preview-1780041822.dev-api.shop.dev', 84, '9292')).toBe( + 'https://admin.shop.dev/store/preview-1780041822/themes/84/editor?hr=9292', + ) + }) +}) + describe('renderLinks', () => { test('renders "dev" command links', async () => { // Given @@ -353,4 +397,56 @@ describe('dev() Ctrl-C analytics', () => { expect(reportAnalyticsEvent).toHaveBeenCalledTimes(1) }) + + test('uses storefront host override for local rendering while keeping Admin host for editor URLs', async () => { + const previewStoreOptions = { + ...baseOptions, + adminSession: {storeFqdn: 'preview-1780041822.dev-api.shop.dev', token: 'x'}, + store: 'preview-1780041822.dev-api.shop.dev', + storefrontHost: 'preview-1780041822.my.shop.dev', + previewUrl: 'https://preview.example.test', + } + + vi.mocked(initializeDevServerSession).mockResolvedValueOnce({ + storeFqdn: previewStoreOptions.adminSession.storeFqdn, + storefrontFqdn: 'preview-1780041822.my.shop.dev', + token: previewStoreOptions.adminSession.token, + } as any) + + const devPromise = dev(previewStoreOptions) + await new Promise((resolve) => setImmediate(resolve)) + resolveBackgroundJob() + await devPromise + + expect(initializeDevServerSession).toHaveBeenCalledWith( + theme.id.toString(), + expect.objectContaining({ + storeFqdn: 'preview-1780041822.dev-api.shop.dev', + storefrontFqdn: 'preview-1780041822.my.shop.dev', + }), + undefined, + undefined, + ) + + expect(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + nextSteps: expect.arrayContaining([ + expect.arrayContaining([ + expect.objectContaining({ + link: expect.objectContaining({ + url: 'https://preview.example.test', + }), + }), + ]), + expect.arrayContaining([ + expect.objectContaining({ + link: expect.objectContaining({ + url: `https://admin.shop.dev/store/preview-1780041822/themes/${theme.id}/editor?hr=9292`, + }), + }), + ]), + ]), + }), + ) + }) }) diff --git a/packages/theme/src/cli/services/dev.ts b/packages/theme/src/cli/services/dev.ts index 6f213e45b87..fdb48085d76 100644 --- a/packages/theme/src/cli/services/dev.ts +++ b/packages/theme/src/cli/services/dev.ts @@ -9,6 +9,7 @@ import {initializeDevServerSession} from '../utilities/theme-environment/dev-ser import {ensureListingExists} from '../utilities/theme-listing.js' import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' import {AdminSession} from '@shopify/cli-kit/node/session' +import {storeAdminUrl} from '@shopify/cli-kit/node/context/fqdn' import {Theme} from '@shopify/cli-kit/node/themes/types' import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/tcp' import {AbortError} from '@shopify/cli-kit/node/error' @@ -25,6 +26,19 @@ import readline from 'readline' const DEFAULT_HOST = '127.0.0.1' const DEFAULT_PORT = '9292' +export function storefrontFqdnForThemeDev(storeFqdn: string, storefrontHost?: string) { + return storefrontHost ?? storeFqdn +} + +export function themeEditorUrlForThemeDev(storeFqdn: string, themeId: number, port: string) { + const adminHost = storeFqdn.endsWith('.dev-api.shop.dev') + ? storeAdminUrl(storeFqdn.replace(/\.dev-api\.shop\.dev$/, '.my.shop.dev')) + : storeAdminUrl(storeFqdn) + const editorPath = adminHost === storeFqdn ? `/admin/themes/${themeId}/editor` : `/themes/${themeId}/editor` + + return `https://${adminHost}${editorPath}?hr=${port}` +} + let hasReportedAnalyticsEvent = false interface DevOptions { @@ -32,6 +46,8 @@ interface DevOptions { commandConfig: Config directory: string store: string + storefrontHost?: string + previewUrl?: string password?: string storePassword?: string open: boolean @@ -100,18 +116,19 @@ export async function dev(options: DevOptions) { } const port = options.port ?? String(await getAvailableTCPPort(Number(DEFAULT_PORT))) + const storefrontStore = storefrontFqdnForThemeDev(options.store, options.storefrontHost) const urls = { local: `http://${host}:${port}`, giftCard: `http://${host}:${port}/gift_cards/[store_id]/preview`, - themeEditor: `https://${options.store}/admin/themes/${options.theme.id}/editor?hr=${port}`, - preview: `https://${options.store}/?preview_theme_id=${options.theme.id}`, + themeEditor: themeEditorUrlForThemeDev(options.store, options.theme.id, port), + preview: options.previewUrl ?? `https://${storefrontStore}/?preview_theme_id=${options.theme.id}`, } const storefrontPassword = await storefrontPasswordPromise const session = await initializeDevServerSession( options.theme.id.toString(), - options.adminSession, + {...options.adminSession, storefrontFqdn: storefrontStore}, options.password, storefrontPassword, ) diff --git a/packages/theme/src/cli/utilities/theme-environment/dev-server-session.test.ts b/packages/theme/src/cli/utilities/theme-environment/dev-server-session.test.ts index d9f28dc5961..6b9e86f0d23 100644 --- a/packages/theme/src/cli/utilities/theme-environment/dev-server-session.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/dev-server-session.test.ts @@ -109,6 +109,33 @@ describe('dev server session', async () => { noPrompt: true, }) }) + + test('preserves a separate storefront host for preview-store rendering', async () => { + // Given + vi.mocked(ensureAuthenticatedStorefront).mockResolvedValue('storefront_token') + vi.mocked(getStorefrontSessionCookies).mockResolvedValue({_shopify_essential: ':cookie:'}) + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue({ + token: 'token_1', + storeFqdn: 'preview-1780041822.dev-api.shop.dev', + }) + + // When + const session = await fetchDevServerSession(themeId, { + token: 'token', + storeFqdn: 'preview-1780041822.dev-api.shop.dev', + storefrontFqdn: 'preview-1780041822.my.shop.dev', + }) + + // Then + expect(getStorefrontSessionCookies).toHaveBeenCalledWith( + 'https://preview-1780041822.my.shop.dev', + 'preview-1780041822.my.shop.dev', + themeId, + undefined, + expect.objectContaining({'X-Shopify-Shop': 'preview-1780041822.my.shop.dev'}), + ) + expect(session).toEqual(expect.objectContaining({storefrontFqdn: 'preview-1780041822.my.shop.dev'})) + }) }) describe('initializeDevServerSession', async () => { diff --git a/packages/theme/src/cli/utilities/theme-environment/dev-server-session.ts b/packages/theme/src/cli/utilities/theme-environment/dev-server-session.ts index 7e2192f3a39..263e145a830 100644 --- a/packages/theme/src/cli/utilities/theme-environment/dev-server-session.ts +++ b/packages/theme/src/cli/utilities/theme-environment/dev-server-session.ts @@ -1,6 +1,6 @@ import {DevServerSession} from './types.js' import {getStorefrontSessionCookies, ShopifyEssentialError} from './storefront-session.js' -import {buildBaseStorefrontUrl} from './storefront-renderer.js' +import {buildBaseStorefrontUrl, storefrontFqdn} from './storefront-renderer.js' import {fetchThemeAssets} from '@shopify/cli-kit/node/themes/api' import {AbortError} from '@shopify/cli-kit/node/error' import {outputDebug, outputContent, outputToken} from '@shopify/cli-kit/node/output' @@ -24,7 +24,7 @@ const REQUIRED_THEME_FILES = ['layout/theme.liquid', 'config/settings_schema.jso */ export async function initializeDevServerSession( themeId: string, - adminSession: AdminSession, + adminSession: AdminSession & {storefrontFqdn?: string}, adminPassword?: string, storefrontPassword?: string, ) { @@ -62,7 +62,7 @@ export async function initializeDevServerSession( */ export async function fetchDevServerSession( themeId: string, - adminSession: AdminSession, + adminSession: AdminSession & {storefrontFqdn?: string}, adminPassword?: string, storefrontPassword?: string, ): Promise { @@ -83,6 +83,7 @@ export async function fetchDevServerSession( return { ...session, + storefrontFqdn: adminSession.storefrontFqdn, sessionCookies, storefrontToken, } @@ -91,13 +92,15 @@ export async function fetchDevServerSession( export async function getStorefrontSessionCookiesWithVerification( storeUrl: string, themeId: string, - adminSession: AdminSession, + adminSession: AdminSession & {storefrontFqdn?: string}, storefrontToken: string, storefrontPassword?: string, ): Promise> { + const storefrontStoreFqdn = storefrontFqdn(adminSession) + try { - return await getStorefrontSessionCookies(storeUrl, adminSession.storeFqdn, themeId, storefrontPassword, { - 'X-Shopify-Shop': adminSession.storeFqdn, + return await getStorefrontSessionCookies(storeUrl, storefrontStoreFqdn, themeId, storefrontPassword, { + 'X-Shopify-Shop': storefrontStoreFqdn, 'X-Shopify-Access-Token': adminSession.token, Authorization: `Bearer ${storefrontToken}`, }) diff --git a/packages/theme/src/cli/utilities/theme-environment/proxy.ts b/packages/theme/src/cli/utilities/theme-environment/proxy.ts index 6f4b765da5f..2abaa1baa6b 100644 --- a/packages/theme/src/cli/utilities/theme-environment/proxy.ts +++ b/packages/theme/src/cli/utilities/theme-environment/proxy.ts @@ -1,5 +1,5 @@ import {cleanHeader, defaultHeaders} from './storefront-utils.js' -import {buildCookies} from './storefront-renderer.js' +import {buildCookies, storefrontFqdn} from './storefront-renderer.js' import {logRequestLine} from '../log-request-line.js' import {createFetchError, extractFetchErrorInfo} from '../errors.js' @@ -116,7 +116,7 @@ export function canProxyRequest(event: H3Event) { } function getStoreFqdnForRegEx(ctx: DevServerContext) { - return ctx.session.storeFqdn.replace(/\\/g, '\\\\').replace(/\./g, '\\.') + return storefrontFqdn(ctx.session).replace(/\\/g, '\\\\').replace(/\./g, '\\.') } /** @@ -307,7 +307,7 @@ export function getProxyStorefrontHeaders(event: H3Event) { export function proxyStorefrontRequest(event: H3Event, ctx: DevServerContext): Promise { const path = event.path.replace(new RegExp(EXTENSION_CDN_PREFIX, 'g'), '/') - const host = event.path.startsWith(EXTENSION_CDN_PREFIX) ? 'cdn.shopify.com' : ctx.session.storeFqdn + const host = event.path.startsWith(EXTENSION_CDN_PREFIX) ? 'cdn.shopify.com' : storefrontFqdn(ctx.session) const url = new URL(path, `https://${host}`) // Check that we aren't redirecting to external hosts diff --git a/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.test.ts b/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.test.ts index 87e32234bea..fc501f3c065 100644 --- a/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.test.ts @@ -89,6 +89,27 @@ describe('render', () => { expect(response.headers.get('something')).toEqual('else') }) + test('renders using storefrontFqdn when Admin API host differs from storefront host', async () => { + // Given + vi.mocked(fetch).mockResolvedValue(new Response(null, {headers: {'Content-Type': 'application/json'}})) + const previewStoreSession = { + ...session, + storeFqdn: 'preview-1780041822.dev-api.shop.dev', + storefrontFqdn: 'preview-1780041822.my.shop.dev', + } + + // When + await render(previewStoreSession, context) + + // Then + expect(fetch).toHaveBeenCalledWith( + 'https://preview-1780041822.my.shop.dev/products/1?_fd=0&pb=0', + expect.objectContaining({ + method: 'GET', + }), + ) + }) + test('renders using theme access API', async () => { // Given vi.mocked(fetch).mockResolvedValue( diff --git a/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.ts b/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.ts index 4a61e423558..d764200356a 100644 --- a/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.ts +++ b/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.ts @@ -136,21 +136,25 @@ function buildStorefrontUrl(session: DevServerSession, {path, sectionId, appBloc return `${url}?${params}` } -export function buildBaseStorefrontUrl(session: AdminSession) { +export function buildBaseStorefrontUrl(session: AdminSession & {storefrontFqdn?: string}) { if (isThemeAccessSession(session)) { return `https://${getThemeKitAccessDomain()}/cli/sfr` } else { - return `https://${session.storeFqdn}` + return `https://${storefrontFqdn(session)}` } } +export function storefrontFqdn(session: AdminSession & {storefrontFqdn?: string}) { + return session.storefrontFqdn ?? session.storeFqdn +} + function isThemeAccessSession(session: AdminSession) { return session.token.startsWith('shptka_') } function themeAccessHeaders(session: DevServerSession) { return { - 'X-Shopify-Shop': session.storeFqdn, + 'X-Shopify-Shop': storefrontFqdn(session), 'X-Shopify-Access-Token': session.token, } } diff --git a/packages/theme/src/cli/utilities/theme-environment/theme-environment.ts b/packages/theme/src/cli/utilities/theme-environment/theme-environment.ts index 0426a527f2b..d49bf73fa0a 100644 --- a/packages/theme/src/cli/utilities/theme-environment/theme-environment.ts +++ b/packages/theme/src/cli/utilities/theme-environment/theme-environment.ts @@ -2,6 +2,7 @@ import {getHotReloadHandler, setupInMemoryTemplateWatcher} from './hot-reload/se import {getHtmlHandler} from './html.js' import {getAssetsHandler} from './local-assets.js' import {getProxyHandler} from './proxy.js' +import {storefrontFqdn} from './storefront-renderer.js' import {reconcileAndPollThemeEditorChanges} from './remote-theme-watcher.js' import {uploadTheme} from '../theme-uploader.js' import {renderTasksToStdErr} from '../theme-ui.js' @@ -130,7 +131,11 @@ interface DevelopmentServerInstance { function createDevelopmentServer(theme: Theme, ctx: DevServerContext, initialWork: Promise) { const app = createApp() - const allowedOrigins = [`http://${ctx.options.host}:${ctx.options.port}`, `https://${ctx.session.storeFqdn}`] + const allowedOrigins = [ + `http://${ctx.options.host}:${ctx.options.port}`, + `https://${ctx.session.storeFqdn}`, + `https://${storefrontFqdn(ctx.session)}`, + ] app.use( defineLazyEventHandler(async () => { diff --git a/packages/theme/src/cli/utilities/theme-environment/types.ts b/packages/theme/src/cli/utilities/theme-environment/types.ts index 9b689c7daf8..889ca2a6c4f 100644 --- a/packages/theme/src/cli/utilities/theme-environment/types.ts +++ b/packages/theme/src/cli/utilities/theme-environment/types.ts @@ -12,6 +12,12 @@ import {ThemeExtensionFileSystem, ThemeFileSystem} from '@shopify/cli-kit/node/t * and includes a field to track when the session was last refreshed. */ export interface DevServerSession extends AdminSession { + /** + * Storefront domain used for SFR/dev-server rendering when it differs from + * the Admin API domain stored in `storeFqdn`. + */ + storefrontFqdn?: string + /** * Token to authenticate section rendering API calls. */