Skip to content
Draft
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
12 changes: 12 additions & 0 deletions packages/theme/src/cli/commands/theme/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
98 changes: 97 additions & 1 deletion packages/theme/src/cli/services/dev.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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(),
}))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`,
}),
}),
]),
]),
}),
)
})
})
23 changes: 20 additions & 3 deletions packages/theme/src/cli/services/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,13 +26,28 @@ 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 {
adminSession: AdminSession
commandConfig: Config
directory: string
store: string
storefrontHost?: string
previewUrl?: string
password?: string
storePassword?: string
open: boolean
Expand Down Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
) {
Expand Down Expand Up @@ -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<DevServerSession> {
Expand All @@ -83,6 +83,7 @@ export async function fetchDevServerSession(

return {
...session,
storefrontFqdn: adminSession.storefrontFqdn,
sessionCookies,
storefrontToken,
}
Expand All @@ -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<Record<string, string>> {
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}`,
})
Expand Down
6 changes: 3 additions & 3 deletions packages/theme/src/cli/utilities/theme-environment/proxy.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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, '\\.')
}

/**
Expand Down Expand Up @@ -307,7 +307,7 @@ export function getProxyStorefrontHeaders(event: H3Event) {

export function proxyStorefrontRequest(event: H3Event, ctx: DevServerContext): Promise<Response> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -130,7 +131,11 @@ interface DevelopmentServerInstance {

function createDevelopmentServer(theme: Theme, ctx: DevServerContext, initialWork: Promise<void>) {
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 () => {
Expand Down
Loading
Loading