Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 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
1 change: 1 addition & 0 deletions packages/pwa-kit-create-app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
- Prompt text for Site ID should match actual validation: Site ID may contain uppercase or lowercase letters, numbers, hyphens, or underscores. - Updated Site ID validator regex to allow both uppercase and lowercase letters for improved compatibility, clarity. [#3410] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3410)

## v3.14.0-dev (Sep 26, 2025)
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe not this PR directly, but why are there two v3.14.0-dev sections?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks @patricksullivansf. It must be the auto merge. I fixed it.

- Added Hybrid Proxy support configuration for local and ODS hybrid development [#3409] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3409)

## v3.13.0 (Sep 25, 2025)
- This features introduces enhancements to the shopping assistant that integrates Salesforce Embedded Messaging Service with PWA Kit applications, adding comprehensive context support, localization capabilities, and improved user experience features. [#3259](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3259)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,54 @@ const options = {
// of the keys of headers that have been encoded
// There may be a slight performance loss with requests/responses with large number
// of headers as we loop through all the headers to verify ASCII vs non ASCII
encodeNonAsciiHttpHeaders: true
encodeNonAsciiHttpHeaders: true,

// Cookie handling configuration for security and session management.
//
// SECURITY CONSIDERATIONS:
// - Set to 'false' in production for enhanced security (prevents XSS attacks via client-side cookie access)
// - Set to 'true' only in development when testing SFCC session integration or Hybrid Proxy functionality
// - When false: cookies are stripped from requests and cannot be set in responses (server-only cookies)
// - When true: allows client-side JavaScript access to cookies (development/testing only)
//
// HYBRID PROXY REQUIREMENT:
// - Hybrid Proxy requires this to be 'true' for SFCC session management to work properly
// - Only enable Hybrid Proxy in development environments, never in production
localAllowCookies: false,

// Hybrid Proxy configuration for local development and MRT to ODS connection testing.
//
// IMPORTANT SECURITY NOTES:
// - This should ONLY be used for local development and testing
// - NEVER enable in production - use eCDN rules instead for production routing
// - When enabled, localAllowCookies must be set to 'true' for SFCC sessions to work
// - Production deployments should use eCDN to direct requests to SFCC instances
//
// REFERENCE: https://developer.salesforce.com/docs/commerce/commerce-api/guide/hybrid-authentication.html
hybridProxy: {
// If this is enabled, the Hybrid Proxy will be enabled to proxy requests to the SFCC instance.
// IMPORTANT: This should only be used for local development. For production, this should be disabled and use eCDN to direct requests to the SFCC instance.
// Refer to https://developer.salesforce.com/docs/commerce/commerce-api/guide/hybrid-authentication.html for more details.
enabled: false,

// The origin of the SFCC instance (i.e. the instance that is being proxied to which hosts the storefront).
sfccOrigin: 'https://{{answers.project.commerce.instanceUrl}}',
Copy link
Contributor Author

Choose a reason for hiding this comment

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

use {{answers.project.commerce.instanceUrl}} so that user does not need to update this manually in ssr.js


// The MRT rules to apply to the hybrid proxy.
// These rules determine which requests are handled by PWA Kit (MRT) vs proxied to SFCC. The same rules should be used in the eCDN rules for the same requests.
// Paths excluded from the rules will be re-directed to SFCC instance. In the following example, the Cart and checkout pages are excluded from the rules.
// Refer to the following links for more details:
// * https://developer.salesforce.com/docs/commerce/commerce-api/references/cdn-api-process-apis?meta=MrtRules
// * https://developer.salesforce.com/docs/commerce/commerce-api/guide/ecdn-rules-for-phased-headless-rollout.html
routingRules: [
// Hybrid Proxy Routing Rules
// Purpose: Route requests between PWA Kit (React) and SFCC (traditional storefront)
// Configuration: site: 'none', locale: 'none' → URLs like /category/womens (no prefixes)
// Logic: URLs matching these patterns → PWA Kit handles them
// URLs NOT matching → proxied to SFCC (e.g., /cart, /checkout)
'http.request.uri.path eq "/" or http.request.uri.path matches "^/callback" or http.request.uri.path matches "^/mobify" or http.request.uri.path matches "^/worker.js" or http.request.uri.path matches "^/login" or http.request.uri.path matches "^/reset-password" or http.request.uri.path matches "^/registration" or http.request.uri.path matches "^/account" or http.request.uri.path matches "^/account/orders" or http.request.uri.path matches "^/account/orders/(\\\\w+)" or http.request.uri.path matches "^/account/wishlist" or http.request.uri.path matches "^/product/(\\\\w+)" or http.request.uri.path matches "^/search" or http.request.uri.path matches "^/category/(\\\\w+)" or http.request.uri.path matches "^/store-locator" or http.request.uri.path matches "^/social-callback" or http.request.uri.path matches "^/passwordless-login-callback" or http.request.uri.path matches "^/passwordless-login-landing" or http.request.uri.path matches "^/reset-password-callback" or http.request.uri.path matches "^/reset-password-landing"'
]
}
}

const runtime = getRuntime()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,54 @@ const options = {
// of the keys of headers that have been encoded
// There may be a slight performance loss with requests/responses with large number
// of headers as we loop through all the headers to verify ASCII vs non ASCII
encodeNonAsciiHttpHeaders: true
encodeNonAsciiHttpHeaders: true,

// Cookie handling configuration for security and session management.
//
// SECURITY CONSIDERATIONS:
// - Set to 'false' in production for enhanced security (prevents XSS attacks via client-side cookie access)
// - Set to 'true' only in development when testing SFCC session integration or Hybrid Proxy functionality
// - When false: cookies are stripped from requests and cannot be set in responses (server-only cookies)
// - When true: allows client-side JavaScript access to cookies (development/testing only)
//
// HYBRID PROXY REQUIREMENT:
// - Hybrid Proxy requires this to be 'true' for SFCC session management to work properly
// - Only enable Hybrid Proxy in development environments, never in production
localAllowCookies: false,

// Hybrid Proxy configuration for local development and MRT to ODS connection testing.
//
// IMPORTANT SECURITY NOTES:
// - This should ONLY be used for local development and testing
// - NEVER enable in production - use eCDN rules instead for production routing
// - When enabled, localAllowCookies must be set to 'true' for SFCC sessions to work
// - Production deployments should use eCDN to direct requests to SFCC instances
//
// REFERENCE: https://developer.salesforce.com/docs/commerce/commerce-api/guide/hybrid-authentication.html
hybridProxy: {
// If this is enabled, the Hybrid Proxy will be enabled to proxy requests to the SFCC instance.
// IMPORTANT: This should only be used for local development. For production, this should be disabled and use eCDN to direct requests to the SFCC instance.
// Refer to https://developer.salesforce.com/docs/commerce/commerce-api/guide/hybrid-authentication.html for more details.
enabled: false,

// The origin of the SFCC instance (i.e. the instance that is being proxied to which hosts the storefront).
sfccOrigin: 'https://{{answers.project.commerce.instanceUrl}}',
Copy link
Contributor Author

Choose a reason for hiding this comment

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

use {{answers.project.commerce.instanceUrl}} so that user does not need to update this manually in ssr.js


// The MRT rules to apply to the hybrid proxy.
// These rules determine which requests are handled by PWA Kit (MRT) vs proxied to SFCC. The same rules should be used in the eCDN rules for the same requests.
// Paths excluded from the rules will be re-directed to SFCC instance. In the following example, the Cart and checkout pages are excluded from the rules.
// Refer to the following links for more details:
// * https://developer.salesforce.com/docs/commerce/commerce-api/references/cdn-api-process-apis?meta=MrtRules
// * https://developer.salesforce.com/docs/commerce/commerce-api/guide/ecdn-rules-for-phased-headless-rollout.html
routingRules: [
// Hybrid Proxy Routing Rules
// Purpose: Route requests between PWA Kit (React) and SFCC (traditional storefront)
// Configuration: site: 'none', locale: 'none' → URLs like /category/womens (no prefixes)
// Logic: URLs matching these patterns → PWA Kit handles them
// URLs NOT matching → proxied to SFCC (e.g., /cart, /checkout)
'http.request.uri.path eq "/" or http.request.uri.path matches "^/callback" or http.request.uri.path matches "^/mobify" or http.request.uri.path matches "^/worker.js" or http.request.uri.path matches "^/login" or http.request.uri.path matches "^/reset-password" or http.request.uri.path matches "^/registration" or http.request.uri.path matches "^/account" or http.request.uri.path matches "^/account/orders" or http.request.uri.path matches "^/account/orders/(\\\\w+)" or http.request.uri.path matches "^/account/wishlist" or http.request.uri.path matches "^/product/(\\\\w+)" or http.request.uri.path matches "^/search" or http.request.uri.path matches "^/category/(\\\\w+)" or http.request.uri.path matches "^/store-locator" or http.request.uri.path matches "^/social-callback" or http.request.uri.path matches "^/passwordless-login-callback" or http.request.uri.path matches "^/passwordless-login-landing" or http.request.uri.path matches "^/reset-password-callback" or http.request.uri.path matches "^/reset-password-landing"'
]
}
}

const runtime = getRuntime()
Expand Down
1 change: 1 addition & 0 deletions packages/pwa-kit-runtime/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## v3.14.0-dev (Sep 26, 2025)
- Replace aws-serverless-express with @h4ad/serverless-adapter [#3325](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3325)
- Added Hybrid Proxy support for local and ODS hybrid development [#3409] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3409)
- Add extensibility hooks for SLAS private client proxy with `onSLASPrivateProxyReq` and `onSLASPrivateProxyRes` callbacks [#3411](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3411)

## v3.13.0 (Sep 25, 2025)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {applyProxyRequestHeaders} from '../../utils/ssr-server/configure-proxy'
import expressLogging from 'morgan'
import logger from '../../utils/logger-instance'
import {createProxyMiddleware, responseInterceptor} from 'http-proxy-middleware'
import {hybridProxy} from '../../utils/ssr-server/hybrid-proxy'
import {convertExpressRouteToRegex} from '../../utils/ssr-server/convert-express-route'
import {ServerlessAdapter} from '@h4ad/serverless-adapter'
import {DefaultHandler} from '@h4ad/serverless-adapter/lib/handlers/default'
Expand Down Expand Up @@ -417,6 +418,7 @@ export const RemoteServerFactory = {
this._setupProxying(app, options)

this._setupSlasPrivateClientProxy(app, options)
this._setupHybridProxy(app, options)

// Beyond this point, we know that this is not a proxy request
// and not a bundle request, so we can apply specific
Expand All @@ -428,6 +430,12 @@ export const RemoteServerFactory = {
return app
},

_setupHybridProxy(app, options) {
if (options.hybridProxy?.enabled) {
app.use(hybridProxy(options))
}
},

/**
* @private
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,31 @@
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import {once, RemoteServerFactory, isBinary} from './build-remote-server'
import {isBinary, once, RemoteServerFactory} from './build-remote-server'
import {X_ENCODED_HEADERS} from './constants'
import {default as createEvent} from '@serverless/event-mocks'
import logger from '../../utils/logger-instance'
import {catchAndLog} from '../../utils/ssr-server'

jest.mock('../../utils/ssr-config', () => {
return {
getConfig: () => {}
}
})

jest.mock('../../utils/ssr-server', () => ({
...jest.requireActual('../../utils/ssr-server'),
catchAndLog: jest.fn()
}))
jest.mock('../../utils/logger-instance', () => ({
__esModule: true,
default: {
warn: jest.fn(),
info: jest.fn(),
error: jest.fn()
}
}))

describe('the once function', () => {
test('should prevent a function being called more than once', () => {
const fn = jest.fn(() => ({test: 'test'}))
Expand Down Expand Up @@ -343,3 +358,184 @@ describe('SLAS private proxy', () => {
}
})
})

describe('errorHandlerMiddleware logic', () => {
it('calls sendMetric and sendStatus(500) when error is handled', () => {
catchAndLog.mockImplementation(() => {})
const req = {app: {sendMetric: jest.fn()}}
const res = {sendStatus: jest.fn()}
const err = new Error('fail')
// Inlined errorHandlerMiddleware logic
catchAndLog(err)
req.app.sendMetric('RenderErrors')
res.sendStatus(500)
expect(req.app.sendMetric).toHaveBeenCalledWith('RenderErrors')
expect(res.sendStatus).toHaveBeenCalledWith(500)
})
})

describe('_setRequestId', () => {
it('sets requestId from correlationId header', () => {
const app = {use: jest.fn()}
RemoteServerFactory._setRequestId(app)
// Grab the actual middleware
const mw = app.use.mock.calls[0][0]
const req = {headers: {'x-correlation-id': 'abc'}}
const res = {locals: {}}
const next = jest.fn()
mw(req, res, next)
expect(res.locals.requestId).toBe('abc')
expect(next).toHaveBeenCalled()
})
it('sets requestId from x-apigateway-event header', () => {
const app = {use: jest.fn()}
RemoteServerFactory._setRequestId(app)
const mw = app.use.mock.calls[0][0]
const req = {headers: {'x-apigateway-event': 'eventid'}}
const res = {locals: {}}
const next = jest.fn()
mw(req, res, next)
expect(res.locals.requestId).toBe('eventid')
expect(next).toHaveBeenCalled()
})
it('logs error if no id headers', () => {
const app = {use: jest.fn()}
RemoteServerFactory._setRequestId(app)
const mw = app.use.mock.calls[0][0]
const req = {headers: {}}
const res = {locals: {}}
const next = jest.fn()
mw(req, res, next)
expect(logger.error).toHaveBeenCalledWith(
'Both x-correlation-id and x-apigateway-event headers are missing',
expect.objectContaining({namespace: '_setRequestId'})
)
expect(next).toHaveBeenCalled()
})
})

describe('_setupHybridProxy', () => {
beforeEach(() => {
jest.clearAllMocks()
})

it('should call app.use with hybridProxy when enabled', () => {
const mockApp = {use: jest.fn()}
const options = {
localAllowCookies: true,
hybridProxy: {
enabled: true,
sfccOrigin: 'https://test.com',
routingRules: ['http.request.uri.path eq "/test"']
}
}

RemoteServerFactory._setupHybridProxy(mockApp, options)

expect(mockApp.use).toHaveBeenCalledWith(expect.any(Function))
expect(mockApp.use).toHaveBeenCalledTimes(1)
})

it('should not call app.use when hybridProxy is disabled', () => {
const mockApp = {use: jest.fn()}
const options = {
hybridProxy: {
enabled: false,
sfccOrigin: 'https://test.com',
routingRules: ['http.request.uri.path eq "/test"']
}
}

RemoteServerFactory._setupHybridProxy(mockApp, options)

expect(mockApp.use).not.toHaveBeenCalled()
})

it('should not call app.use when hybridProxy is undefined', () => {
const mockApp = {use: jest.fn()}
const options = {}

RemoteServerFactory._setupHybridProxy(mockApp, options)

expect(mockApp.use).not.toHaveBeenCalled()
})

it('should not call app.use when hybridProxy is null', () => {
const mockApp = {use: jest.fn()}
const options = {
hybridProxy: null
}

RemoteServerFactory._setupHybridProxy(mockApp, options)

expect(mockApp.use).not.toHaveBeenCalled()
})

it('should not call app.use when hybridProxy.enabled is undefined', () => {
const mockApp = {use: jest.fn()}
const options = {
hybridProxy: {
sfccOrigin: 'https://test.com',
routingRules: ['http.request.uri.path eq "/test"']
}
}

RemoteServerFactory._setupHybridProxy(mockApp, options)

expect(mockApp.use).not.toHaveBeenCalled()
})

it('should call app.use when hybridProxy.enabled is explicitly true', () => {
const mockApp = {use: jest.fn()}
const options = {
localAllowCookies: true,
hybridProxy: {
enabled: true,
sfccOrigin: 'https://test.com',
routingRules: ['http.request.uri.path eq "/test"']
}
}

RemoteServerFactory._setupHybridProxy(mockApp, options)

expect(mockApp.use).toHaveBeenCalledWith(expect.any(Function))
expect(mockApp.use).toHaveBeenCalledTimes(1)
})

it('should call app.use when hybridProxy.enabled is truthy string', () => {
const mockApp = {use: jest.fn()}
const options = {
localAllowCookies: true,
hybridProxy: {
enabled: 'true', // truthy string
sfccOrigin: 'https://test.com',
routingRules: ['http.request.uri.path eq "/test"']
}
}

RemoteServerFactory._setupHybridProxy(mockApp, options)

expect(mockApp.use).toHaveBeenCalledWith(expect.any(Function))
expect(mockApp.use).toHaveBeenCalledTimes(1)
})

it('should not call app.use when hybridProxy.enabled is falsy', () => {
const mockApp = {use: jest.fn()}
const falsyValues = [false, 0, '', null, undefined]

falsyValues.forEach((falsyValue) => {
jest.clearAllMocks()
const options = {
hybridProxy: {
enabled: falsyValue,
sfccOrigin: 'https://test.com',
routingRules: ['http.request.uri.path eq "/test"']
}
}

RemoteServerFactory._setupHybridProxy(mockApp, options)

expect(mockApp.use).not.toHaveBeenCalled()
})
})
})
6 changes: 6 additions & 0 deletions packages/pwa-kit-runtime/src/utils/logger-factory.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ describe('PWAKitLogger', () => {
expect(console.info).toHaveBeenCalledWith('testNamespace INFO This is an info message')
})

test('should handle log method (alias for info)', () => {
const logger = createLogger({packageName: 'test-package'})
logger.log('This is a log message')
expect(console.info).toHaveBeenCalledWith('test-package INFO This is a log message')
})

describe('serializeError method', () => {
let logger

Expand Down
Loading
Loading