Skip to content

Commit f6cc6e2

Browse files
authored
Merge pull request #7377 from Shopify/fd-sfapi-fix
Pass Storefront API requests through the dev proxy as-is
2 parents 3a92d03 + 8f4e546 commit f6cc6e2

3 files changed

Lines changed: 112 additions & 24 deletions

File tree

.changeset/itchy-jobs-report.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/theme': patch
3+
---
4+
5+
Fix theme dev proxy to support SFAPI requests.

packages/theme/src/cli/utilities/theme-environment/proxy.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
patchRenderingResponse,
66
proxyStorefrontRequest,
77
} from './proxy.js'
8-
import {describe, test, expect} from 'vitest'
8+
import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest'
99
import {createEvent} from 'h3'
1010
import {IncomingMessage, ServerResponse} from 'node:http'
1111

@@ -404,4 +404,78 @@ describe('dev proxy', () => {
404404
)
405405
})
406406
})
407+
408+
describe('proxyStorefrontRequest — Storefront API passthrough', () => {
409+
const passthroughCtx = {
410+
...ctx,
411+
type: 'theme',
412+
session: {
413+
storeFqdn: 'my-store.myshopify.com',
414+
sessionCookies: {_shopify_essential: 'essential-value'},
415+
storefrontToken: 'sfr-devtools-token',
416+
},
417+
} as unknown as DevServerContext
418+
419+
let fetchMock: ReturnType<typeof vi.fn>
420+
421+
beforeEach(() => {
422+
fetchMock = vi.fn().mockResolvedValue(new Response('{"data":{}}'))
423+
vi.stubGlobal('fetch', fetchMock)
424+
})
425+
426+
afterEach(() => {
427+
vi.unstubAllGlobals()
428+
})
429+
430+
test('forwards /api/YYYY-MM/graphql.json without injecting theme auth, cookies, referer, or dev params', async () => {
431+
const event = createH3Event('POST', '/api/2026-01/graphql.json', {
432+
'x-shopify-storefront-access-token': 'public-access-token',
433+
authorization: 'Bearer client-supplied-token',
434+
})
435+
436+
await proxyStorefrontRequest(event, passthroughCtx)
437+
438+
expect(fetchMock).toHaveBeenCalledOnce()
439+
const [requestUrl, init] = fetchMock.mock.calls[0] as [URL, RequestInit]
440+
441+
expect(requestUrl.toString()).toBe('https://my-store.myshopify.com/api/2026-01/graphql.json')
442+
expect(requestUrl.searchParams.has('_fd')).toBe(false)
443+
expect(requestUrl.searchParams.has('pb')).toBe(false)
444+
445+
const headers = init.headers as Record<string, string>
446+
expect(headers['x-shopify-storefront-access-token']).toBe('public-access-token')
447+
expect(headers.authorization).toBe('Bearer client-supplied-token')
448+
expect(headers.Authorization).toBeUndefined()
449+
expect(headers.Cookie).toBeUndefined()
450+
expect(headers.referer).toBeUndefined()
451+
})
452+
453+
test('forwards /api/unstable/graphql.json through the passthrough path', async () => {
454+
const event = createH3Event('POST', '/api/unstable/graphql.json')
455+
456+
await proxyStorefrontRequest(event, passthroughCtx)
457+
458+
expect(fetchMock).toHaveBeenCalledOnce()
459+
const [requestUrl, init] = fetchMock.mock.calls[0] as [URL, RequestInit]
460+
461+
expect(requestUrl.toString()).toBe('https://my-store.myshopify.com/api/unstable/graphql.json')
462+
const headers = init.headers as Record<string, string>
463+
expect(headers.Authorization).toBeUndefined()
464+
expect(headers.Cookie).toBeUndefined()
465+
})
466+
467+
test('does not passthrough non-matching paths (e.g. /api/2026-01/graphql.js) — falls back to SFR auth injection', async () => {
468+
const event = createH3Event('POST', '/api/2026-01/graphql.js')
469+
470+
await proxyStorefrontRequest(event, passthroughCtx)
471+
472+
expect(fetchMock).toHaveBeenCalledOnce()
473+
const [requestUrl, init] = fetchMock.mock.calls[0] as [URL, RequestInit]
474+
475+
expect(requestUrl.searchParams.get('_fd')).toBe('0')
476+
expect(requestUrl.searchParams.get('pb')).toBe('0')
477+
const headers = init.headers as Record<string, string>
478+
expect(headers.Authorization).toBe('Bearer sfr-devtools-token')
479+
})
480+
})
407481
})

packages/theme/src/cli/utilities/theme-environment/proxy.ts

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const CHECKOUT_PATTERN = /^\/checkouts\/(?!internal\/)/
2020
const ACCOUNT_PATTERN = /^\/account(\/login\/multipass(\/[^/]+)?|\/logout)?\/?$/
2121
const VANITY_CDN_PATTERN = new RegExp(`^${VANITY_CDN_PREFIX}`)
2222
const EXTENSION_CDN_PATTERN = new RegExp(`^${EXTENSION_CDN_PREFIX}`)
23+
const STOREFRONT_API_PATTERN = /^\/api\/(unstable|\d{4}-\d{2})\/graphql\.json/
2324

2425
const IGNORED_ENDPOINTS = [
2526
'/.well-known',
@@ -118,6 +119,16 @@ function getStoreFqdnForRegEx(ctx: DevServerContext) {
118119
return ctx.session.storeFqdn.replace(/\\/g, '\\\\').replace(/\./g, '\\.')
119120
}
120121

122+
/**
123+
* Whether the request should be forwarded to SFR without modification.
124+
*/
125+
function isPassthroughRequest(event: H3Event) {
126+
// Forward Storefront API requests as-is. The public Storefront API expects
127+
// X-Shopify-Storefront-Access-Token from the caller and rejects our SFR
128+
// devtools bearer, so we must not inject theme auth, cookies, or dev params.
129+
return STOREFRONT_API_PATTERN.test(event.path)
130+
}
131+
121132
/**
122133
* Replaces every VanityCDN-like (...myshopify.com/cdn/...) URL to pass through the local server.
123134
* It also replaces MainCDN-like (cdn.shopify.com/...) URLs to files that are known local assets.
@@ -306,41 +317,39 @@ export function proxyStorefrontRequest(event: H3Event, ctx: DevServerContext): P
306317
)
307318
}
308319

309-
// When a .css.liquid or .js.liquid file is requested but it doesn't exist in SFR,
310-
// it will be rendered with a query string like `assets/file.css?1234`.
311-
// For some reason, after refreshing, this rendered URL keeps the wrong `?1234`
312-
// query string for a while. We replace it with a proper timestamp here to fix it.
313-
if (/\/assets\/[^/]+\.(css|js)$/.test(url.pathname) && /\?\d+$/.test(url.search)) {
314-
url.search = `?v=${Date.now()}`
315-
}
316-
317-
url.searchParams.set('_fd', '0')
318-
url.searchParams.set('pb', '0')
319-
const headers = getProxyStorefrontHeaders(event)
320320
const body = getRequestWebStream(event)
321+
let headers = getProxyStorefrontHeaders(event)
322+
323+
if (!isPassthroughRequest(event)) {
324+
// When a .css.liquid or .js.liquid file is requested but it doesn't exist in SFR,
325+
// it will be rendered with a query string like `assets/file.css?1234`.
326+
// For some reason, after refreshing, this rendered URL keeps the wrong `?1234`
327+
// query string for a while. We replace it with a proper timestamp here to fix it.
328+
if (/\/assets\/[^/]+\.(css|js)$/.test(url.pathname) && /\?\d+$/.test(url.search)) {
329+
url.search = `?v=${Date.now()}`
330+
}
321331

322-
const baseHeaders: Record<string, string> = {
323-
...headers,
324-
...defaultHeaders(),
325-
referer: url.origin,
326-
Cookie: buildCookies(ctx.session, {headers}),
327-
}
332+
url.searchParams.set('_fd', '0')
333+
url.searchParams.set('pb', '0')
328334

329-
// Only include Authorization for theme dev, not theme-extensions
330-
if (ctx.type === 'theme') {
331-
baseHeaders.Authorization = `Bearer ${ctx.session.storefrontToken}`
335+
headers = cleanHeader({
336+
...headers,
337+
...defaultHeaders(),
338+
referer: url.origin,
339+
Cookie: buildCookies(ctx.session, {headers}),
340+
// Only include Authorization for theme dev, not theme-extensions
341+
...(ctx.type === 'theme' ? {Authorization: `Bearer ${ctx.session.storefrontToken}`} : {}),
342+
})
332343
}
333344

334-
const finalHeaders = cleanHeader(baseHeaders)
335-
336345
// eslint-disable-next-line no-restricted-globals
337346
return fetch(url, {
338347
method: event.method,
339348
body,
340349
duplex: body ? 'half' : undefined,
341350
// Important to return 3xx responses to the client
342351
redirect: 'manual',
343-
headers: finalHeaders,
352+
headers,
344353
} as RequestInit & {duplex?: 'half'})
345354
.then((response) => patchProxiedResponseHeaders(ctx, response))
346355
.catch((error: Error) => {

0 commit comments

Comments
 (0)