Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
2 changes: 2 additions & 0 deletions packages/pwa-kit-create-app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
## v3.16.0-dev (Dec 17, 2025)
- Move envBasePath into ssrParameters [#3590](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3590)

## v3.15.0 (Dec 17, 2025)
- Add new Google Cloud API configuration and Bonus Product configuration [#3523](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3523)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,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
Expand Down
2 changes: 2 additions & 0 deletions packages/pwa-kit-dev/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
## v3.16.0-dev (Dec 17, 2025)
- Move envBasePath into ssrParameters [#3590](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3590)

## v3.15.0 (Dec 17, 2025)

## v3.14.0 (Nov 04, 2025)
Expand Down
7 changes: 6 additions & 1 deletion packages/pwa-kit-dev/bin/pwa-kit-dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,15 @@ const main = async () => {
process.exit(1)
}

// Load config to get envBasePath for local development
const config = getConfig() || {}
const envBasePath = config.ssrParameters?.envBasePath || ''
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add a comment describing why we need to read from ssrParameters only for local development

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment added


execSync(`${babelNode} ${inspect ? '--inspect' : ''} ${babelArgs} ${entrypoint}`, {
env: {
...process.env,
...(noHMR ? {HMR: 'false'} : {})
...(noHMR ? {HMR: 'false'} : {}),
...(envBasePath ? {MRT_ENV_BASE_PATH: envBasePath} : {})
}
})
})
Expand Down
2 changes: 2 additions & 0 deletions packages/pwa-kit-react-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
## v3.16.0-dev (Dec 17, 2025)
- Move envBasePath into ssrParameters [#3590](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3590)

## v3.15.0 (Dec 17, 2025)

## v3.14.0 (Nov 04, 2025)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ const renderApp = (args) => {
__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)
Expand Down
2 changes: 2 additions & 0 deletions packages/pwa-kit-runtime/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
## v3.16.0-dev (Dec 17, 2025)
- Move envBasePath into ssrParameters [#3590](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3590)

## v3.15.0 (Dec 17, 2025)
- Fix multiple set-cookie headers [#3508](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3508)

Expand Down
32 changes: 10 additions & 22 deletions packages/pwa-kit-runtime/src/ssr/server/express.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1318,8 +1319,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())

Expand All @@ -1332,7 +1337,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())

Expand All @@ -1354,7 +1359,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())

Expand All @@ -1371,7 +1376,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())

Expand All @@ -1390,25 +1395,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())

Expand Down
46 changes: 18 additions & 28 deletions packages/pwa-kit-runtime/src/utils/ssr-namespace-paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -29,38 +26,31 @@ const SLAS_PRIVATE_CLIENT_PROXY_PATH = `${MOBIFY_PATH}/slas/private`
* Returns an empty string if the base path is not set or is '/'.
*/
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
Expand Down
119 changes: 92 additions & 27 deletions packages/pwa-kit-runtime/src/utils/ssr-namespace-paths.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<script>'
expect(() => getEnvBasePath()).toThrow('Invalid envBasePath configuration')
})

test('getEnvBasePath throws error for envBasePath with whitespace', () => {
process.env.MRT_ENV_BASE_PATH = ' /sample '
expect(() => getEnvBasePath()).toThrow('Invalid envBasePath configuration')
})

test('getEnvBasePath throws error for multi-part base paths with slashes', () => {
process.env.MRT_ENV_BASE_PATH = '/test/sample'
expect(() => getEnvBasePath()).toThrow('Invalid envBasePath configuration')
})

test('getEnvBasePath normalizes envBasePath', () => {
jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({envBasePath: ' //sample/ '})
expect(getEnvBasePath()).toBe('/sample')
test('getEnvBasePath allows special characters: . + $ ~ " \' @ : -', () => {
process.env.MRT_ENV_BASE_PATH = '/a.b+c$d~e"f\'g@h:i-j_k'
expect(getEnvBasePath()).toBe('/a.b+c$d~e"f\'g@h:i-j_k')
})

test('getEnvBasePath throws error if base path exceeds 64 characters', () => {
// 65 characters total (1 slash + 64 chars)
process.env.MRT_ENV_BASE_PATH = '/' + 'a'.repeat(64)
expect(() => getEnvBasePath()).toThrow('Invalid envBasePath configuration')
})

test('getEnvBasePath allows base path of exactly 64 characters', () => {
// 64 characters total (1 slash + 63 chars)
process.env.MRT_ENV_BASE_PATH = '/' + 'a'.repeat(63)
expect(getEnvBasePath()).toBe('/' + 'a'.repeat(63))
})
})

test('getEnvBasePath works with multiple part base path', () => {
jest.spyOn(ssrConfig, 'getConfig').mockReturnValue({envBasePath: '//test/sample/ '})
expect(getEnvBasePath()).toBe('/test/sample')
describe('Browser environment (window)', () => {
beforeEach(() => {
global.window = {}
})

test('getEnvBasePath returns base path from window global', () => {
global.window.__MRT_ENV_BASE_PATH__ = '/sample'
expect(getEnvBasePath()).toBe('/sample')
})

test('getEnvBasePath returns empty string if window global is not set', () => {
expect(getEnvBasePath()).toBe('')
})

test('getEnvBasePath throws error for base path with trailing slash from window global', () => {
global.window.__MRT_ENV_BASE_PATH__ = '/sample/'
expect(() => getEnvBasePath()).toThrow('Invalid envBasePath configuration')
})

test('getEnvBasePath throws error for window global value with whitespace', () => {
global.window.__MRT_ENV_BASE_PATH__ = ' /sample '
expect(() => getEnvBasePath()).toThrow('Invalid envBasePath configuration')
})

test('getEnvBasePath throws error if invalid characters in window global', () => {
global.window.__MRT_ENV_BASE_PATH__ = '/sample<script>'
expect(() => getEnvBasePath()).toThrow('Invalid envBasePath configuration')
})

test('getEnvBasePath throws error for multi-part base paths in window global', () => {
global.window.__MRT_ENV_BASE_PATH__ = '/test/sample'
expect(() => getEnvBasePath()).toThrow('Invalid envBasePath configuration')
})
})
})
1 change: 0 additions & 1 deletion packages/template-mrt-reference-app/config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
*/

module.exports = {
envBasePath: '/',
ssrEnabled: true,
ssrOnly: ['ssr.js', 'ssr.js.map', 'node_modules/**/*.*'],
ssrShared: [
Expand Down
1 change: 1 addition & 0 deletions packages/template-retail-react-app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- [Feature] PWA Integration with OMS
- Integrate Order Details page to display orders data from OMS [#3573](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3573)
- Integrate Order History page to display data from OMS [#3581](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3581)
- Move envBasePath into ssrParameters [#3590](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3590)

## v8.3.0 (Dec 17, 2025)
- [Bugfix] Fix Forgot Password link not working from Account Profile password update form [#3493](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3493)
Expand Down
1 change: 0 additions & 1 deletion packages/template-retail-react-app/config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ module.exports = {
apiKey: process.env.GOOGLE_CLOUD_API_KEY
}
},
envBasePath: '/',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the same default value in ssrParameters?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. The default value is for the envBasePath to be undefined so that it is not set on MRT or local.

Once it is defined, it must be a valid value (ie. /shop). The changes in this PR disallow setting envBasePath to just /

externals: [],
pageNotFoundURL: '/page-not-found',
ssrEnabled: true,
Expand Down
Loading