diff --git a/packages/commerce-sdk-react/CHANGELOG.md b/packages/commerce-sdk-react/CHANGELOG.md index 92ad74a14c..b1a52bfbb7 100644 --- a/packages/commerce-sdk-react/CHANGELOG.md +++ b/packages/commerce-sdk-react/CHANGELOG.md @@ -1,3 +1,6 @@ +## v5.2.0-dev +- Update storefront preview to support base paths [#3614](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3614) + ## v5.1.0-dev - Add Page Designer Support [#3727](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3727) - Bump commerce-sdk-isomorphic to 5.1.0 [#3725](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3725) @@ -7,6 +10,8 @@ ## v5.0.0 (Feb 12, 2026) - Upgrade to commerce-sdk-isomorphic v5.0.0 and introduce Payment Instrument SCAPI integration [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552) + +## v4.4.0-dev (Dec 17, 2025) - [Bugfix] Ensure code_verifier can be optional in resetPassword call [#3567](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3567) - [Improvement] Strengthening typescript types on custom endpoint options and fetchOptions types [#3589](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3589) - [Feature] Update `authorizePasswordless`, `getPasswordResetToken`, and `resetPassword` to support use of `email` mode [#3525](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3525) diff --git a/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.test.tsx b/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.test.tsx index e365bbbc48..694bf8dcfd 100644 --- a/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.test.tsx +++ b/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.test.tsx @@ -14,7 +14,16 @@ import {useCommerceApi, useConfig} from '../../hooks' declare global { interface Window { - STOREFRONT_PREVIEW?: Record + STOREFRONT_PREVIEW?: { + getToken?: () => string | undefined | Promise + onContextChange?: () => void | Promise + siteId?: string + experimentalUnsafeNavigate?: ( + path: string | {pathname: string; search?: string; hash?: string; state?: unknown}, + action?: 'push' | 'replace', + ...args: unknown[] + ) => void + } } } @@ -28,9 +37,24 @@ jest.mock('./utils', () => { jest.mock('../../auth/index.ts') jest.mock('../../hooks/useConfig', () => jest.fn()) +const mockPush = jest.fn() +const mockReplace = jest.fn() +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom') + return { + ...actual, + useHistory: () => ({ + push: mockPush, + replace: mockReplace + }) + } +}) + describe('Storefront Preview Component', function () { beforeEach(() => { delete window.STOREFRONT_PREVIEW + mockPush.mockClear() + mockReplace.mockClear() ;(useConfig as jest.Mock).mockReturnValue({siteId: 'site-id'}) }) afterEach(() => { @@ -107,6 +131,88 @@ describe('Storefront Preview Component', function () { expect(window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate).toBeDefined() }) + test('experimentalUnsafeNavigate removes base path from path when getBasePath is provided', () => { + ;(detectStorefrontPreview as jest.Mock).mockReturnValue(true) + + render( + 'my-token'} + getBasePath={() => '/mybase'} + /> + ) + + window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.('/mybase/product/123', 'push') + expect(mockPush).toHaveBeenCalledWith('/product/123') + + mockPush.mockClear() + window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.('/mybase/account', 'replace') + expect(mockReplace).toHaveBeenCalledWith('/account') + }) + + test('experimentalUnsafeNavigate does not remove when path does not start with base path', () => { + ;(detectStorefrontPreview as jest.Mock).mockReturnValue(true) + + render( + 'my-token'} + getBasePath={() => '/mybase'} + /> + ) + + window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.('/other/product/123', 'push') + expect(mockPush).toHaveBeenCalledWith('/other/product/123') + }) + + test('experimentalUnsafeNavigate does not strip when path has basePath only as substring (e.g. /shop vs /shopping/cart)', () => { + ;(detectStorefrontPreview as jest.Mock).mockReturnValue(true) + + render( + 'my-token'} + getBasePath={() => '/shop'} + /> + ) + + window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.('/shopping/cart', 'push') + expect(mockPush).toHaveBeenCalledWith('/shopping/cart') + }) + + test('experimentalUnsafeNavigate strips to / when path exactly equals basePath', () => { + ;(detectStorefrontPreview as jest.Mock).mockReturnValue(true) + + render( + 'my-token'} + getBasePath={() => '/mybase'} + /> + ) + + window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.('/mybase', 'push') + expect(mockPush).toHaveBeenCalledWith('/') + }) + + test('experimentalUnsafeNavigate removes base path from location object when getBasePath is provided', () => { + ;(detectStorefrontPreview as jest.Mock).mockReturnValue(true) + + render( + 'my-token'} + getBasePath={() => '/mybase'} + /> + ) + + window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.( + {pathname: '/mybase/product/123', search: '?q=1'}, + 'push' + ) + expect(mockPush).toHaveBeenCalledWith({pathname: '/product/123', search: '?q=1'}) + }) + test('cache breaker is added to the parameters of SCAPI requests, only if in storefront preview', () => { ;(detectStorefrontPreview as jest.Mock).mockReturnValue(true) mockQueryEndpoint('baskets/123', {}) diff --git a/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.tsx b/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.tsx index 408d5e19e2..289b85c936 100644 --- a/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.tsx +++ b/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.tsx @@ -17,20 +17,57 @@ type GetToken = () => string | undefined | Promise type ContextChangeHandler = () => void | Promise type OptionalWhenDisabled = ({enabled?: true} & T) | ({enabled: false} & Partial) +/** + * Remove the base path from a path string. + * Only strips when path equals basePath or path starts with basePath + '/'. + */ +function removeBasePathFromPath(path: string, basePath: string): string { + const matches = + path.startsWith(basePath + '/') || path === basePath + return matches ? path.slice(basePath.length) || '/' : path +} + +/** + * Strip the base path from a path + * + * React Router history re-adds the base path to the path, so we + * remove it here to avoid base path duplication. + */ +function removeBasePathFromLocation( + pathOrLocation: LocationDescriptor, + basePath: string +): LocationDescriptor { + if (!basePath) return pathOrLocation + if (typeof pathOrLocation === 'string') { + return removeBasePathFromPath(pathOrLocation, basePath) as LocationDescriptor + } + const pathname = pathOrLocation.pathname ?? '/' + return { + ...pathOrLocation, + pathname: removeBasePathFromPath(pathname, basePath) + } +} + /** * * @param enabled - flag to turn on/off Storefront Preview feature. By default, it is set to true. * This flag only applies if storefront is running in a Runtime Admin iframe. * @param getToken - A method that returns the access token for the current user + * @param getBasePath - A method that returns the router base path of the app. */ export const StorefrontPreview = ({ children, enabled = true, getToken, - onContextChange + onContextChange, + getBasePath }: React.PropsWithChildren< // Props are only required when Storefront Preview is enabled - OptionalWhenDisabled<{getToken: GetToken; onContextChange?: ContextChangeHandler}> + OptionalWhenDisabled<{ + getToken: GetToken + onContextChange?: ContextChangeHandler + getBasePath?: () => string + }> >) => { const history = useHistory() const isHostTrusted = detectStorefrontPreview() @@ -39,6 +76,13 @@ export const StorefrontPreview = ({ useEffect(() => { if (enabled && isHostTrusted) { + if (process.env.NODE_ENV !== 'production' && !getBasePath) { + console.warn( + '[StorefrontPreview] No getBasePath prop provided. ' + + 'If your app uses a base path for router routes (showBasePath is true in url config), ' + + 'pass getBasePath to avoid base path duplication during navigation.' + ) + } window.STOREFRONT_PREVIEW = { ...window.STOREFRONT_PREVIEW, getToken, @@ -49,11 +93,13 @@ export const StorefrontPreview = ({ action: 'push' | 'replace' = 'push', ...args: unknown[] ) => { - history[action](path, ...args) + const basePath = getBasePath?.() ?? '' + const pathWithoutBase = removeBasePathFromLocation(path, basePath) + history[action](pathWithoutBase, ...args) } } } - }, [enabled, getToken, onContextChange, siteId]) + }, [enabled, getToken, onContextChange, siteId, getBasePath]) useEffect(() => { if (enabled && isHostTrusted) { @@ -99,7 +145,8 @@ StorefrontPreview.propTypes = { // to get to a place where both these props are simply optional and we will provide default implementations. // This would make the API simpler to use. getToken: CustomPropTypes.requiredFunctionWhenEnabled, - onContextChange: PropTypes.func + onContextChange: PropTypes.func, + getBasePath: PropTypes.func } export default StorefrontPreview diff --git a/packages/commerce-sdk-react/src/components/StorefrontPreview/utils.ts b/packages/commerce-sdk-react/src/components/StorefrontPreview/utils.ts index e0c6a4db1a..dccb189c6b 100644 --- a/packages/commerce-sdk-react/src/components/StorefrontPreview/utils.ts +++ b/packages/commerce-sdk-react/src/components/StorefrontPreview/utils.ts @@ -26,7 +26,6 @@ export const detectStorefrontPreview = () => { export const getClientScript = () => { const parentOrigin = getParentOrigin() ?? 'https://runtime.commercecloud.com' return parentOrigin === DEVELOPMENT_ORIGIN - // TODO: This will need to be updated to support base paths with storefront preview ? `${parentOrigin}${LOCAL_BUNDLE_PATH}/static/storefront-preview.js` : `${parentOrigin}/cc/b2c/preview/preview.client.js` } diff --git a/packages/pwa-kit-create-app/CHANGELOG.md b/packages/pwa-kit-create-app/CHANGELOG.md index 253737d5aa..05f96fbd0c 100644 --- a/packages/pwa-kit-create-app/CHANGELOG.md +++ b/packages/pwa-kit-create-app/CHANGELOG.md @@ -1,3 +1,6 @@ +## v3.18.0-dev +- Add base path prefix to support multiple MRT environments under 1 domain [#3614](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3614) + ## v3.17.0-dev - Add Salesforce Payments configuration to generated projects [#3725] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3725) - Clear verdaccio npm cache during project generation [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index bf3c8b32c7..499eb0dbcc 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -48,6 +48,9 @@ module.exports = { locale: 'path', // This boolean value dictates whether or not default site or locale values are shown in the url. Defaults to: false showDefaults: true, + // This boolean value dictates whether or not the base path, defined in ssrParameters.envBasePath, + // is shown in shopper facing urls. Defaults to: false + showBasePath: false, {{else}} // Determine where the siteRef is located. Valid values include 'path|query_param|none'. Defaults to: 'none' site: 'none', @@ -55,6 +58,9 @@ module.exports = { locale: 'none', // This boolean value dictates whether or not default site or locale values are shown in the url. Defaults to: false showDefaults: false, + // This boolean value dictates whether or not the base path, defined in ssrParameters.envBasePath, + // is shown in shopper facing urls. Defaults to: false + showBasePath: false, {{/if}} // This boolean value dictates whether the plus sign (+) is interpreted as space for query param string. Defaults to: false interpretPlusSignAsSpace: false @@ -176,10 +182,6 @@ module.exports = { apiKey: process.env.GOOGLE_CLOUD_API_KEY } }, - // Experimental: The base path for the app. This is the path that will be prepended to all /mobify routes, - // callback routes, and Express routes. - // Setting this to `/` or an empty string will result in the above routes not having a base path. - envBasePath: '/', // This list contains server-side only libraries that you don't want to be compiled by webpack externals: [], // Page not found url for your app diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs index d79e599024..2f93eb6b8b 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/config/default.js.hbs @@ -48,6 +48,9 @@ module.exports = { locale: 'path', // This boolean value dictates whether default site or locale values are shown in the url. Defaults to: false showDefaults: true, + // This boolean value dictates whether or not the base path, defined in ssrParameters.envBasePath, + // is shown in shopper facing urls. Defaults to: false + showBasePath: false, {{else}} // Determine where the siteRef is located. Valid values include 'path|query_param|none'. Defaults to: 'none' site: 'none', @@ -55,6 +58,9 @@ module.exports = { locale: 'none', // This boolean value dictates whether or not default site or locale values are shown in the url. Defaults to: false showDefaults: false, + // This boolean value dictates whether or not the base path, defined in ssrParameters.envBasePath, + // is shown in shopper facing urls. Defaults to: false + showBasePath: false, {{/if}} // This boolean value dictates whether the plus sign (+) is interpreted as space for query param string. Defaults to: false interpretPlusSignAsSpace: false diff --git a/packages/pwa-kit-dev/CHANGELOG.md b/packages/pwa-kit-dev/CHANGELOG.md index 6d59e5da08..5c5deaf944 100644 --- a/packages/pwa-kit-dev/CHANGELOG.md +++ b/packages/pwa-kit-dev/CHANGELOG.md @@ -1,3 +1,6 @@ +## v3.18.0-dev +- Add base path prefix to support multiple MRT environments under 1 domain [#3614](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3614) + ## v3.17.0-dev - Add Page Designer Design CSS Support [#3727](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3727) - Update jest, archiver and remove rimraf dependencies [#3663](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3663) diff --git a/packages/pwa-kit-dev/bin/pwa-kit-dev.js b/packages/pwa-kit-dev/bin/pwa-kit-dev.js index e07e4e30b7..8cf62927ec 100755 --- a/packages/pwa-kit-dev/bin/pwa-kit-dev.js +++ b/packages/pwa-kit-dev/bin/pwa-kit-dev.js @@ -254,10 +254,16 @@ const main = async () => { process.exit(1) } + // Load config to get envBasePath from ssrParameters for local development + // This mimics how MRT sets the MRT_ENV_BASE_PATH system environment variable + const config = getConfig() || {} + const envBasePath = config.ssrParameters?.envBasePath || '' + execSync(`${babelNode} ${inspect ? '--inspect' : ''} ${babelArgs} ${entrypoint}`, { env: { ...process.env, - ...(noHMR ? {HMR: 'false'} : {}) + ...(noHMR ? {HMR: 'false'} : {}), + ...(envBasePath ? {MRT_ENV_BASE_PATH: envBasePath} : {}) } }) }) diff --git a/packages/pwa-kit-react-sdk/CHANGELOG.md b/packages/pwa-kit-react-sdk/CHANGELOG.md index d7111479e6..ac2ff2f29f 100644 --- a/packages/pwa-kit-react-sdk/CHANGELOG.md +++ b/packages/pwa-kit-react-sdk/CHANGELOG.md @@ -1,3 +1,6 @@ +## v3.18.0-dev +- Add base path prefix to support multiple MRT environments under 1 domain [#3614](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3614) + ## v3.17.0-dev - Update test setup for Jest 29 compatibility [#3663](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3663) - Add Node 24 support. Drop Node 16 support [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) diff --git a/packages/pwa-kit-react-sdk/src/ssr/browser/main.jsx b/packages/pwa-kit-react-sdk/src/ssr/browser/main.jsx index 79b09b2790..92bb229e86 100644 --- a/packages/pwa-kit-react-sdk/src/ssr/browser/main.jsx +++ b/packages/pwa-kit-react-sdk/src/ssr/browser/main.jsx @@ -17,6 +17,7 @@ import {loadableReady} from '@loadable/component' import {uuidv4} from '../../utils/uuidv4.client' import PropTypes from 'prop-types' import logger from '../../utils/logger-instance' +import {getRouterBasePath} from '../universal/utils' /* istanbul ignore next */ export const registerServiceWorker = (url) => { @@ -44,10 +45,11 @@ export const registerServiceWorker = (url) => { export const OuterApp = ({routes, error, WrappedApp, locals, onHydrate}) => { const AppConfig = getAppConfig() const isInitialPageRef = useRef(true) + const routerBasename = getRouterBasePath() || undefined return ( - + { // If we are hydrating an error page use the server correlation id. diff --git a/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.js b/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.js index ed745a8ea4..3b18b959cf 100644 --- a/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.js +++ b/packages/pwa-kit-react-sdk/src/ssr/server/react-rendering.js @@ -40,6 +40,8 @@ import logger from '../../utils/logger-instance' import PerformanceTimer from '../../utils/performance' import {PERFORMANCE_MARKS} from '../../utils/performance-marks' +import {getRouterBasePath} from '../universal/utils' + const CWD = process.cwd() const BUNDLES_PATH = path.resolve(CWD, 'build/loadable-stats.json') @@ -270,9 +272,11 @@ export const render = (req, res, next) => { const OuterApp = ({req, res, error, App, appState, routes, routerContext, location}) => { const AppConfig = getAppConfig() + const routerBasename = getRouterBasePath() || undefined + return ( - + { __CONFIG__: config, __PRELOADED_STATE__: appState, __ERROR__: error, + __MRT_ENV_BASE_PATH__: process.env.MRT_ENV_BASE_PATH || '', // `window.Progressive` has a long history at Mobify and some // client-side code depends on it. Maintain its name out of tradition. Progressive: getWindowProgressive(req, res) diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/components/refresh/index.jsx b/packages/pwa-kit-react-sdk/src/ssr/universal/components/refresh/index.jsx index 2725092261..7d408925cc 100644 --- a/packages/pwa-kit-react-sdk/src/ssr/universal/components/refresh/index.jsx +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/components/refresh/index.jsx @@ -8,6 +8,7 @@ import React, {useEffect} from 'react' import {useHistory, useLocation} from 'react-router-dom' import {useQueryClient} from '@tanstack/react-query' import logger from '../../../../utils/logger-instance' +import {getRouterBasePath} from '../../utils' // For good UX, show loading spinner long enough for users to see const LOADING_SPINNER_MIN_DURATION = 500 @@ -53,6 +54,13 @@ const Refresh = () => { }) referrer = '/' } + + // Remove the base path before navigating (React Router will add it back) + const basePath = getRouterBasePath() + if (basePath && (referrer.startsWith(basePath + '/') || referrer === basePath)) { + referrer = referrer.slice(basePath.length) || '/' + } + history.replace(referrer) } refetchData() diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/components/refresh/index.test.js b/packages/pwa-kit-react-sdk/src/ssr/universal/components/refresh/index.test.js index ee51a523f5..3c2f962658 100644 --- a/packages/pwa-kit-react-sdk/src/ssr/universal/components/refresh/index.test.js +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/components/refresh/index.test.js @@ -9,6 +9,7 @@ import {render, screen, waitFor} from '@testing-library/react' import React from 'react' import {useHistory, useLocation} from 'react-router-dom' import Refresh from './index' +import {getRouterBasePath} from '../../utils' jest.useFakeTimers() @@ -34,6 +35,10 @@ jest.mock('@tanstack/react-query', () => { } }) +jest.mock('../../utils', () => ({ + getRouterBasePath: jest.fn(() => '') +})) + test('renders a loading spinner initially', () => { render() expect(screen.getByTestId('loading-spinner')).toBeInTheDocument() @@ -85,3 +90,54 @@ test('navigate to homepage if `referrer` search param cannot be found in the pag expect(useHistory().replace).toHaveBeenCalledWith('/') }) }) + +test('strips base path from referrer when basePath is set', async () => { + const basePath = '/my-base' + getRouterBasePath.mockReturnValue(basePath) + useLocation.mockImplementationOnce(() => ({ + search: `?referrer=${encodeURIComponent('/my-base/some-page')}` + })) + + render() + jest.runAllTimers() + + await waitFor(() => { + expect(useHistory().replace).toHaveBeenCalledWith('/some-page') + }) + + getRouterBasePath.mockReturnValue('') +}) + +test('strips base path from referrer when referrer equals basePath exactly', async () => { + const basePath = '/my-base' + getRouterBasePath.mockReturnValue(basePath) + useLocation.mockImplementationOnce(() => ({ + search: `?referrer=${encodeURIComponent('/my-base')}` + })) + + render() + jest.runAllTimers() + + await waitFor(() => { + expect(useHistory().replace).toHaveBeenCalledWith('/') + }) + + getRouterBasePath.mockReturnValue('') +}) + +test('does not strip base path when referrer does not start with basePath', async () => { + const basePath = '/my-base' + getRouterBasePath.mockReturnValue(basePath) + useLocation.mockImplementationOnce(() => ({ + search: `?referrer=${encodeURIComponent('/other-path/page')}` + })) + + render() + jest.runAllTimers() + + await waitFor(() => { + expect(useHistory().replace).toHaveBeenCalledWith('/other-path/page') + }) + + getRouterBasePath.mockReturnValue('') +}) diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/utils.client.test.js b/packages/pwa-kit-react-sdk/src/ssr/universal/utils.client.test.js index 9e856a501c..eebbb51d26 100644 --- a/packages/pwa-kit-react-sdk/src/ssr/universal/utils.client.test.js +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/utils.client.test.js @@ -5,6 +5,15 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import * as utils from './utils' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import {getEnvBasePath} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths' + +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ + getConfig: jest.fn() +})) +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths', () => ({ + getEnvBasePath: jest.fn() +})) describe('getProxyConfigs (client-side)', () => { const configs = [{foo: 'bar'}] @@ -17,6 +26,14 @@ describe('getProxyConfigs (client-side)', () => { test('should return proxy configs set on window.Progressive', () => { expect(utils.getProxyConfigs()).toEqual(configs) }) + test('should return empty array when ssrOptions is missing', () => { + global.Progressive = {} + expect(utils.getProxyConfigs()).toEqual([]) + }) + test('should return empty array when proxyConfigs is missing', () => { + global.Progressive = {ssrOptions: {}} + expect(utils.getProxyConfigs()).toEqual([]) + }) }) describe('getAssetUrl (client-side)', () => { @@ -33,3 +50,57 @@ describe('getAssetUrl (client-side)', () => { expect(utils.getAssetUrl('/path')).toBe('test.com/path') }) }) + +describe('getRouterBasePath (client-side)', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('should return base path when showBasePath is true', () => { + const mockBasePath = '/test-base' + getEnvBasePath.mockReturnValue(mockBasePath) + getConfig.mockReturnValue({ + app: { + url: { + showBasePath: true + } + } + }) + + expect(utils.getRouterBasePath()).toBe(mockBasePath) + }) + + test('should return empty string when showBasePath is undefined', () => { + getConfig.mockReturnValue({ + app: { + url: {} + } + }) + + expect(utils.getRouterBasePath()).toBe('') + }) + + test('should return empty string when showBasePath is false', () => { + getConfig.mockReturnValue({ + app: { + url: { + showBasePath: false + } + } + }) + + expect(utils.getRouterBasePath()).toBe('') + }) + + test('should return empty string when app config is missing', () => { + getConfig.mockReturnValue({}) + + expect(utils.getRouterBasePath()).toBe('') + }) + + test('should return empty string when getConfig returns null', () => { + getConfig.mockReturnValue(null) + + expect(utils.getRouterBasePath()).toBe('') + }) +}) diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/utils.js b/packages/pwa-kit-react-sdk/src/ssr/universal/utils.js index ada90c474a..ec94813bb4 100644 --- a/packages/pwa-kit-react-sdk/src/ssr/universal/utils.js +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/utils.js @@ -9,6 +9,7 @@ */ import {proxyConfigs} from '@salesforce/pwa-kit-runtime/utils/ssr-shared' import {getEnvBasePath, bundleBasePath} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' const onClient = typeof window !== 'undefined' @@ -53,3 +54,21 @@ export const getProxyConfigs = () => { // Clone to avoid accidental mutation of important configuration variables. return configs.map((config) => ({...config})) } + +/** + * Returns the base path (defined in config.ssrParameters.envBasePath) + * for React Router routes when showBasePath is enabled in the app config. + * + * This function should be used when working with a React Router route + * (The route is defined in routes.jsx). + * + * Use getEnvBasePath (pwa-kit-runtime) if you are working with an express route\ + * (The route is defined in ssr.js). + * + * @returns {string} - The base path if showBasePath is true, otherwise an empty string + */ +export const getRouterBasePath = () => { + const config = getConfig() + const showBasePath = config?.app?.url?.showBasePath === true + return showBasePath ? getEnvBasePath() : '' +} diff --git a/packages/pwa-kit-react-sdk/src/ssr/universal/utils.server.test.js b/packages/pwa-kit-react-sdk/src/ssr/universal/utils.server.test.js index fbb948ff35..a53aa6c0b9 100644 --- a/packages/pwa-kit-react-sdk/src/ssr/universal/utils.server.test.js +++ b/packages/pwa-kit-react-sdk/src/ssr/universal/utils.server.test.js @@ -13,9 +13,66 @@ import * as utils from './utils' import {proxyConfigs} from '@salesforce/pwa-kit-runtime/utils/ssr-shared' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import {getEnvBasePath} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths' + +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ + getConfig: jest.fn() +})) +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths', () => ({ + getEnvBasePath: jest.fn() +})) describe('getProxyConfigs (server-side)', () => { test('should return the currently used proxy configs', () => { expect(utils.getProxyConfigs()).toEqual(proxyConfigs) }) }) + +describe('getRouterBasePath (server-side)', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('should return base path when showBasePath is true', () => { + const mockBasePath = '/test-base' + getEnvBasePath.mockReturnValue(mockBasePath) + getConfig.mockReturnValue({ + app: { + url: { + showBasePath: true + } + } + }) + + expect(utils.getRouterBasePath()).toBe(mockBasePath) + }) + + test('should return empty string when showBasePath is undefined', () => { + getConfig.mockReturnValue({ + app: { + url: {} + } + }) + + expect(utils.getRouterBasePath()).toBe('') + }) + + test('should return empty string when showBasePath is false', () => { + getConfig.mockReturnValue({ + app: { + url: { + showBasePath: false + } + } + }) + + expect(utils.getRouterBasePath()).toBe('') + }) + + test('should return empty string when app config is missing', () => { + getConfig.mockReturnValue({}) + + expect(utils.getRouterBasePath()).toBe('') + }) +}) diff --git a/packages/pwa-kit-runtime/CHANGELOG.md b/packages/pwa-kit-runtime/CHANGELOG.md index f98c8b4482..0484f5b9bd 100644 --- a/packages/pwa-kit-runtime/CHANGELOG.md +++ b/packages/pwa-kit-runtime/CHANGELOG.md @@ -1,3 +1,6 @@ +## v3.18.0-dev +- Add base path prefix to support multiple MRT environments under 1 domain [#3614](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3614) + ## v3.17.0-dev - Add Node 24 support. Migrate deprecated Node.js `url.parse()` and `url.format()` to the WHATWG `URL` [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) diff --git a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js index 04fb15604d..d74b4d790e 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js +++ b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js @@ -601,8 +601,9 @@ export const RemoteServerFactory = { return next() } - // For other routes, only proceed if path actually starts with base path - if (!req.path.startsWith(basePath)) { + // For other routes, only proceed if path equals basePath or path starts with basePath + '/' + const pathMatchesBasePath = req.path === basePath || req.path.startsWith(basePath + '/') + if (!pathMatchesBasePath) { return next() } diff --git a/packages/pwa-kit-runtime/src/ssr/server/express.test.js b/packages/pwa-kit-runtime/src/ssr/server/express.test.js index 59b8cf7f41..9429cebe5a 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/express.test.js +++ b/packages/pwa-kit-runtime/src/ssr/server/express.test.js @@ -21,6 +21,7 @@ import {CachedResponse} from '../../utils/ssr-server' // the file it was defined in, because of the way jest works. import * as ssrServerUtils from '../../utils/ssr-server/utils' import * as ssrConfig from '../../utils/ssr-config' +import * as ssrNamespacePaths from '../../utils/ssr-namespace-paths' import {RemoteServerFactory, REMOTE_REQUIRED_ENV_VARS} from './build-remote-server' import {X_MOBIFY_QUERYSTRING} from './constants' import { @@ -1396,8 +1397,12 @@ describe('SLAS private client proxy', () => { }) describe('Base path tests', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + test('Base path is removed from /mobify request path and still gets through to /mobify endpoint', async () => { - jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({envBasePath: '/basepath'}) + jest.spyOn(ssrNamespacePaths, 'getEnvBasePath').mockReturnValue('/basepath') const app = RemoteServerFactory._createApp(opts()) @@ -1410,7 +1415,7 @@ describe('Base path tests', () => { test('should not remove base path from non /mobify non-express routes', async () => { // Set base path to something that might also be a site id used by react router routes - jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({envBasePath: '/us'}) + jest.spyOn(ssrNamespacePaths, 'getEnvBasePath').mockReturnValue('/us') const app = RemoteServerFactory._createApp(opts()) @@ -1432,7 +1437,7 @@ describe('Base path tests', () => { }, 15000) test('should remove base path from routes with path parameters', async () => { - jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({envBasePath: '/basepath'}) + jest.spyOn(ssrNamespacePaths, 'getEnvBasePath').mockReturnValue('/basepath') const app = RemoteServerFactory._createApp(opts()) @@ -1449,7 +1454,7 @@ describe('Base path tests', () => { }, 15000) test('should remove base path from routes defined with regex', async () => { - jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({envBasePath: '/basepath'}) + jest.spyOn(ssrNamespacePaths, 'getEnvBasePath').mockReturnValue('/basepath') const app = RemoteServerFactory._createApp(opts()) @@ -1468,25 +1473,8 @@ describe('Base path tests', () => { }) }, 15000) - test('remove base path can handle multi-part base paths', async () => { - jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({envBasePath: '/my/base/path'}) - - const app = RemoteServerFactory._createApp(opts()) - - app.get('/api/test', (req, res) => { - res.status(200).json({message: 'test'}) - }) - - return request(app) - .get('/my/base/path/api/test') - .then((response) => { - expect(response.status).toBe(200) - expect(response.body.message).toBe('test') - }) - }, 15000) - test('should handle optional characters in route pattern', async () => { - jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({envBasePath: '/basepath'}) + jest.spyOn(ssrNamespacePaths, 'getEnvBasePath').mockReturnValue('/basepath') const app = RemoteServerFactory._createApp(opts()) diff --git a/packages/pwa-kit-runtime/src/utils/ssr-namespace-paths.js b/packages/pwa-kit-runtime/src/utils/ssr-namespace-paths.js index 6a7c867cfd..41358192ce 100644 --- a/packages/pwa-kit-runtime/src/utils/ssr-namespace-paths.js +++ b/packages/pwa-kit-runtime/src/utils/ssr-namespace-paths.js @@ -5,9 +5,6 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import {getConfig} from './ssr-config' -import logger from './logger-instance' - /** * This file defines the /mobify paths used to set up our Express endpoints. * @@ -26,41 +23,42 @@ const SLAS_PRIVATE_CLIENT_PROXY_PATH = `${MOBIFY_PATH}/slas/private` /* * Returns the base path. This is prepended to a /mobify path. - * Returns an empty string if the base path is not set or is '/'. + * Returns an empty string if the base path is not set. + * Throws an error if the base path is not valid. + * + * Use this function if you are working with an express route + * (ie. The route is defined in ssr.js). + * + * Use getRouterBasePath (pwa-kit-react-sdk) if you are working + * with a React Router route + * (ie. The route is defined in routes.jsx). */ export const getEnvBasePath = () => { - const config = getConfig() - let basePath = config?.envBasePath || '' + let basePath = '' - if (typeof basePath !== 'string') { - logger.warn('Invalid envBasePath configuration. No base path is applied.', { - namespace: 'ssr-namespace-paths.getEnvBasePath' - }) - return '' + if (typeof window !== 'undefined') { + basePath = window.__MRT_ENV_BASE_PATH__ || '' + } else { + basePath = process.env.MRT_ENV_BASE_PATH || '' } - // Normalize the base path - basePath = basePath - .trim() - .replace(/^\/?/, '/') // Ensure leading slash - .replace(/\/+/g, '/') // Normalize multiple slashes - .replace(/\/$/, '') // Remove trailing slash - - // Return empty string for root path or empty result - if (basePath === '/' || !basePath) { + // Return empty string if no base path is set + if (!basePath) { return '' } - // only allow simple, safe characters - // eslint-disable-next-line no-useless-escape - if (!/^\/[a-zA-Z0-9\-_\/]*$/.test(basePath)) { - logger.warn( - 'Invalid envBasePath configuration. Only letters, numbers, hyphens, underscores, and slashes allowed. No base path is applied.', - { - namespace: 'ssr-namespace-paths.getEnvBasePath' - } + // MRT will throw an error on bundle upload if the base path does not match + // the following regex: /^\/[a-zA-Z0-9_.+$~"'@:-]{1,63}$/ + // This validates: + // - Starts with / + // - Followed by 1-63 characters (letters, numbers, and special chars: - _ . + $ ~ " ' @ :) + // - No additional slashes (multi-part paths not allowed, no trailing slashes) + // - No spaces + // - Total max length of 64 characters (1 slash + 63 chars) + if (!/^\/[a-zA-Z0-9_.+$~"'@:-]{1,63}$/.test(basePath)) { + throw new Error( + "Invalid envBasePath configuration. Base path must start with '/' followed by 1-63 characters. Only letters, numbers, and the following special characters are allowed: - _ . + $ ~ \" ' @ :" ) - return '' } return basePath diff --git a/packages/pwa-kit-runtime/src/utils/ssr-namespace-paths.test.js b/packages/pwa-kit-runtime/src/utils/ssr-namespace-paths.test.js index 6d10d1238f..daa48e06c5 100644 --- a/packages/pwa-kit-runtime/src/utils/ssr-namespace-paths.test.js +++ b/packages/pwa-kit-runtime/src/utils/ssr-namespace-paths.test.js @@ -5,43 +5,108 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import {getEnvBasePath} from './ssr-namespace-paths' -import * as ssrConfig from './ssr-config' - -jest.mock('./ssr-config') describe('ssr-namespace-paths tests', () => { - test('getEnvBasePath returns base path from config', () => { - jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({envBasePath: '/sample'}) - expect(getEnvBasePath()).toBe('/sample') - }) + const originalEnv = process.env - test('getEnvBasePath returns empty string if no base path is set', () => { - jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({}) - expect(getEnvBasePath()).toBe('') + beforeEach(() => { + jest.resetModules() + process.env = {...originalEnv} + delete process.env.MRT_ENV_BASE_PATH + // Ensure we're in Node environment (no window) + delete global.window }) - test('getEnvBasePath returns empty string if envBasePath is not a string', () => { - jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({envBasePath: 123}) - expect(getEnvBasePath()).toBe('') + afterEach(() => { + process.env = originalEnv + delete global.window }) - test('getEnvBasePath removes trailing slash', () => { - jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({envBasePath: '/sample/'}) - expect(getEnvBasePath()).toBe('/sample') - }) + describe('Node environment (process.env)', () => { + test('getEnvBasePath returns base path from environment variable', () => { + process.env.MRT_ENV_BASE_PATH = '/sample' + expect(getEnvBasePath()).toBe('/sample') + }) - test('getEnvBasePath returns empty string if invalid cahracters are detected in envBasePath', () => { - jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({envBasePath: '/sample.*'}) - expect(getEnvBasePath()).toBe('') - }) + test('getEnvBasePath returns empty string if no base path is set', () => { + expect(getEnvBasePath()).toBe('') + }) + + test('getEnvBasePath throws error for base path with trailing slash', () => { + process.env.MRT_ENV_BASE_PATH = '/sample/' + expect(() => getEnvBasePath()).toThrow('Invalid envBasePath configuration') + }) + + test('getEnvBasePath throws error for just a slash', () => { + process.env.MRT_ENV_BASE_PATH = '/' + expect(() => getEnvBasePath()).toThrow('Invalid envBasePath configuration') + }) + + test('getEnvBasePath throws error if invalid characters are detected in envBasePath', () => { + process.env.MRT_ENV_BASE_PATH = '/sample