Skip to content

Commit 9765651

Browse files
authored
@W-19869537 Added Hybrid Proxy support for local and ODS hybrid development when no eCDN available (#3409)
1 parent e7f146c commit 9765651

File tree

18 files changed

+1595
-8
lines changed

18 files changed

+1595
-8
lines changed

packages/pwa-kit-create-app/CHANGELOG.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11

2-
## v3.14.0-dev (Oct 20, 2025)
3-
- 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)
4-
52
## v3.14.0-dev (Sep 26, 2025)
3+
- 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)
4+
- Added Hybrid Proxy support configuration for local and ODS hybrid development [#3409] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3409)
65

76
## v3.13.0 (Sep 25, 2025)
87
- 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)

packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/ssr.js.hbs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,54 @@ const options = {
5757
// of the keys of headers that have been encoded
5858
// There may be a slight performance loss with requests/responses with large number
5959
// of headers as we loop through all the headers to verify ASCII vs non ASCII
60-
encodeNonAsciiHttpHeaders: true
60+
encodeNonAsciiHttpHeaders: true,
61+
62+
// Cookie handling configuration for security and session management.
63+
//
64+
// SECURITY CONSIDERATIONS:
65+
// - Set to 'false' in production for enhanced security (prevents XSS attacks via client-side cookie access)
66+
// - Set to 'true' only in development when testing SFCC session integration or Hybrid Proxy functionality
67+
// - When false: cookies are stripped from requests and cannot be set in responses (server-only cookies)
68+
// - When true: allows client-side JavaScript access to cookies (development/testing only)
69+
//
70+
// HYBRID PROXY REQUIREMENT:
71+
// - Hybrid Proxy requires this to be 'true' for SFCC session management to work properly
72+
// - Only enable Hybrid Proxy in development environments, never in production
73+
localAllowCookies: false,
74+
75+
// Hybrid Proxy configuration for local development and MRT to ODS connection testing.
76+
//
77+
// IMPORTANT SECURITY NOTES:
78+
// - This should ONLY be used for local development and testing
79+
// - NEVER enable in production - use eCDN rules instead for production routing
80+
// - When enabled, localAllowCookies must be set to 'true' for SFCC sessions to work
81+
// - Production deployments should use eCDN to direct requests to SFCC instances
82+
//
83+
// REFERENCE: https://developer.salesforce.com/docs/commerce/commerce-api/guide/hybrid-authentication.html
84+
hybridProxy: {
85+
// If this is enabled, the Hybrid Proxy will be enabled to proxy requests to the SFCC instance.
86+
// 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.
87+
// Refer to https://developer.salesforce.com/docs/commerce/commerce-api/guide/hybrid-authentication.html for more details.
88+
enabled: false,
89+
90+
// The origin of the SFCC instance (i.e. the instance that is being proxied to which hosts the storefront).
91+
sfccOrigin: 'https://{{answers.project.commerce.instanceUrl}}',
92+
93+
// The MRT rules to apply to the hybrid proxy.
94+
// 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.
95+
// 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.
96+
// Refer to the following links for more details:
97+
// * https://developer.salesforce.com/docs/commerce/commerce-api/references/cdn-api-process-apis?meta=MrtRules
98+
// * https://developer.salesforce.com/docs/commerce/commerce-api/guide/ecdn-rules-for-phased-headless-rollout.html
99+
routingRules: [
100+
// Hybrid Proxy Routing Rules
101+
// Purpose: Route requests between PWA Kit (React) and SFCC (traditional storefront)
102+
// Configuration: site: 'none', locale: 'none' → URLs like /category/womens (no prefixes)
103+
// Logic: URLs matching these patterns → PWA Kit handles them
104+
// URLs NOT matching → proxied to SFCC (e.g., /cart, /checkout)
105+
'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"'
106+
]
107+
}
61108
}
62109

63110
const runtime = getRuntime()

packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/ssr.js.hbs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,54 @@ const options = {
5757
// of the keys of headers that have been encoded
5858
// There may be a slight performance loss with requests/responses with large number
5959
// of headers as we loop through all the headers to verify ASCII vs non ASCII
60-
encodeNonAsciiHttpHeaders: true
60+
encodeNonAsciiHttpHeaders: true,
61+
62+
// Cookie handling configuration for security and session management.
63+
//
64+
// SECURITY CONSIDERATIONS:
65+
// - Set to 'false' in production for enhanced security (prevents XSS attacks via client-side cookie access)
66+
// - Set to 'true' only in development when testing SFCC session integration or Hybrid Proxy functionality
67+
// - When false: cookies are stripped from requests and cannot be set in responses (server-only cookies)
68+
// - When true: allows client-side JavaScript access to cookies (development/testing only)
69+
//
70+
// HYBRID PROXY REQUIREMENT:
71+
// - Hybrid Proxy requires this to be 'true' for SFCC session management to work properly
72+
// - Only enable Hybrid Proxy in development environments, never in production
73+
localAllowCookies: false,
74+
75+
// Hybrid Proxy configuration for local development and MRT to ODS connection testing.
76+
//
77+
// IMPORTANT SECURITY NOTES:
78+
// - This should ONLY be used for local development and testing
79+
// - NEVER enable in production - use eCDN rules instead for production routing
80+
// - When enabled, localAllowCookies must be set to 'true' for SFCC sessions to work
81+
// - Production deployments should use eCDN to direct requests to SFCC instances
82+
//
83+
// REFERENCE: https://developer.salesforce.com/docs/commerce/commerce-api/guide/hybrid-authentication.html
84+
hybridProxy: {
85+
// If this is enabled, the Hybrid Proxy will be enabled to proxy requests to the SFCC instance.
86+
// 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.
87+
// Refer to https://developer.salesforce.com/docs/commerce/commerce-api/guide/hybrid-authentication.html for more details.
88+
enabled: false,
89+
90+
// The origin of the SFCC instance (i.e. the instance that is being proxied to which hosts the storefront).
91+
sfccOrigin: 'https://{{answers.project.commerce.instanceUrl}}',
92+
93+
// The MRT rules to apply to the hybrid proxy.
94+
// 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.
95+
// 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.
96+
// Refer to the following links for more details:
97+
// * https://developer.salesforce.com/docs/commerce/commerce-api/references/cdn-api-process-apis?meta=MrtRules
98+
// * https://developer.salesforce.com/docs/commerce/commerce-api/guide/ecdn-rules-for-phased-headless-rollout.html
99+
routingRules: [
100+
// Hybrid Proxy Routing Rules
101+
// Purpose: Route requests between PWA Kit (React) and SFCC (traditional storefront)
102+
// Configuration: site: 'none', locale: 'none' → URLs like /category/womens (no prefixes)
103+
// Logic: URLs matching these patterns → PWA Kit handles them
104+
// URLs NOT matching → proxied to SFCC (e.g., /cart, /checkout)
105+
'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"'
106+
]
107+
}
61108
}
62109

63110
const runtime = getRuntime()

packages/pwa-kit-runtime/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## v3.14.0-dev (Sep 26, 2025)
22
- Replace aws-serverless-express with @h4ad/serverless-adapter [#3325](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3325)
3+
- Added Hybrid Proxy support for local and ODS hybrid development [#3409] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3409)
34
- Add extensibility hooks for SLAS private client proxy with `onSLASPrivateProxyReq` and `onSLASPrivateProxyRes` callbacks [#3411](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3411)
45

56
## v3.13.0 (Sep 25, 2025)

packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {applyProxyRequestHeaders} from '../../utils/ssr-server/configure-proxy'
5252
import expressLogging from 'morgan'
5353
import logger from '../../utils/logger-instance'
5454
import {createProxyMiddleware, responseInterceptor} from 'http-proxy-middleware'
55+
import {hybridProxy} from '../../utils/ssr-server/hybrid-proxy'
5556
import {convertExpressRouteToRegex} from '../../utils/ssr-server/convert-express-route'
5657
import {ServerlessAdapter} from '@h4ad/serverless-adapter'
5758
import {DefaultHandler} from '@h4ad/serverless-adapter/lib/handlers/default'
@@ -417,6 +418,7 @@ export const RemoteServerFactory = {
417418
this._setupProxying(app, options)
418419

419420
this._setupSlasPrivateClientProxy(app, options)
421+
this._setupHybridProxy(app, options)
420422

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

433+
_setupHybridProxy(app, options) {
434+
if (options.hybridProxy?.enabled) {
435+
app.use(hybridProxy(options))
436+
}
437+
},
438+
431439
/**
432440
* @private
433441
*/

packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,31 @@
44
* SPDX-License-Identifier: BSD-3-Clause
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
7-
import {once, RemoteServerFactory, isBinary} from './build-remote-server'
7+
import {isBinary, once, RemoteServerFactory} from './build-remote-server'
88
import {X_ENCODED_HEADERS} from './constants'
99
import {default as createEvent} from '@serverless/event-mocks'
10+
import logger from '../../utils/logger-instance'
11+
import {catchAndLog} from '../../utils/ssr-server'
1012

1113
jest.mock('../../utils/ssr-config', () => {
1214
return {
1315
getConfig: () => {}
1416
}
1517
})
1618

19+
jest.mock('../../utils/ssr-server', () => ({
20+
...jest.requireActual('../../utils/ssr-server'),
21+
catchAndLog: jest.fn()
22+
}))
23+
jest.mock('../../utils/logger-instance', () => ({
24+
__esModule: true,
25+
default: {
26+
warn: jest.fn(),
27+
info: jest.fn(),
28+
error: jest.fn()
29+
}
30+
}))
31+
1732
describe('the once function', () => {
1833
test('should prevent a function being called more than once', () => {
1934
const fn = jest.fn(() => ({test: 'test'}))
@@ -343,3 +358,184 @@ describe('SLAS private proxy', () => {
343358
}
344359
})
345360
})
361+
362+
describe('errorHandlerMiddleware logic', () => {
363+
it('calls sendMetric and sendStatus(500) when error is handled', () => {
364+
catchAndLog.mockImplementation(() => {})
365+
const req = {app: {sendMetric: jest.fn()}}
366+
const res = {sendStatus: jest.fn()}
367+
const err = new Error('fail')
368+
// Inlined errorHandlerMiddleware logic
369+
catchAndLog(err)
370+
req.app.sendMetric('RenderErrors')
371+
res.sendStatus(500)
372+
expect(req.app.sendMetric).toHaveBeenCalledWith('RenderErrors')
373+
expect(res.sendStatus).toHaveBeenCalledWith(500)
374+
})
375+
})
376+
377+
describe('_setRequestId', () => {
378+
it('sets requestId from correlationId header', () => {
379+
const app = {use: jest.fn()}
380+
RemoteServerFactory._setRequestId(app)
381+
// Grab the actual middleware
382+
const mw = app.use.mock.calls[0][0]
383+
const req = {headers: {'x-correlation-id': 'abc'}}
384+
const res = {locals: {}}
385+
const next = jest.fn()
386+
mw(req, res, next)
387+
expect(res.locals.requestId).toBe('abc')
388+
expect(next).toHaveBeenCalled()
389+
})
390+
it('sets requestId from x-apigateway-event header', () => {
391+
const app = {use: jest.fn()}
392+
RemoteServerFactory._setRequestId(app)
393+
const mw = app.use.mock.calls[0][0]
394+
const req = {headers: {'x-apigateway-event': 'eventid'}}
395+
const res = {locals: {}}
396+
const next = jest.fn()
397+
mw(req, res, next)
398+
expect(res.locals.requestId).toBe('eventid')
399+
expect(next).toHaveBeenCalled()
400+
})
401+
it('logs error if no id headers', () => {
402+
const app = {use: jest.fn()}
403+
RemoteServerFactory._setRequestId(app)
404+
const mw = app.use.mock.calls[0][0]
405+
const req = {headers: {}}
406+
const res = {locals: {}}
407+
const next = jest.fn()
408+
mw(req, res, next)
409+
expect(logger.error).toHaveBeenCalledWith(
410+
'Both x-correlation-id and x-apigateway-event headers are missing',
411+
expect.objectContaining({namespace: '_setRequestId'})
412+
)
413+
expect(next).toHaveBeenCalled()
414+
})
415+
})
416+
417+
describe('_setupHybridProxy', () => {
418+
beforeEach(() => {
419+
jest.clearAllMocks()
420+
})
421+
422+
it('should call app.use with hybridProxy when enabled', () => {
423+
const mockApp = {use: jest.fn()}
424+
const options = {
425+
localAllowCookies: true,
426+
hybridProxy: {
427+
enabled: true,
428+
sfccOrigin: 'https://test.com',
429+
routingRules: ['http.request.uri.path eq "/test"']
430+
}
431+
}
432+
433+
RemoteServerFactory._setupHybridProxy(mockApp, options)
434+
435+
expect(mockApp.use).toHaveBeenCalledWith(expect.any(Function))
436+
expect(mockApp.use).toHaveBeenCalledTimes(1)
437+
})
438+
439+
it('should not call app.use when hybridProxy is disabled', () => {
440+
const mockApp = {use: jest.fn()}
441+
const options = {
442+
hybridProxy: {
443+
enabled: false,
444+
sfccOrigin: 'https://test.com',
445+
routingRules: ['http.request.uri.path eq "/test"']
446+
}
447+
}
448+
449+
RemoteServerFactory._setupHybridProxy(mockApp, options)
450+
451+
expect(mockApp.use).not.toHaveBeenCalled()
452+
})
453+
454+
it('should not call app.use when hybridProxy is undefined', () => {
455+
const mockApp = {use: jest.fn()}
456+
const options = {}
457+
458+
RemoteServerFactory._setupHybridProxy(mockApp, options)
459+
460+
expect(mockApp.use).not.toHaveBeenCalled()
461+
})
462+
463+
it('should not call app.use when hybridProxy is null', () => {
464+
const mockApp = {use: jest.fn()}
465+
const options = {
466+
hybridProxy: null
467+
}
468+
469+
RemoteServerFactory._setupHybridProxy(mockApp, options)
470+
471+
expect(mockApp.use).not.toHaveBeenCalled()
472+
})
473+
474+
it('should not call app.use when hybridProxy.enabled is undefined', () => {
475+
const mockApp = {use: jest.fn()}
476+
const options = {
477+
hybridProxy: {
478+
sfccOrigin: 'https://test.com',
479+
routingRules: ['http.request.uri.path eq "/test"']
480+
}
481+
}
482+
483+
RemoteServerFactory._setupHybridProxy(mockApp, options)
484+
485+
expect(mockApp.use).not.toHaveBeenCalled()
486+
})
487+
488+
it('should call app.use when hybridProxy.enabled is explicitly true', () => {
489+
const mockApp = {use: jest.fn()}
490+
const options = {
491+
localAllowCookies: true,
492+
hybridProxy: {
493+
enabled: true,
494+
sfccOrigin: 'https://test.com',
495+
routingRules: ['http.request.uri.path eq "/test"']
496+
}
497+
}
498+
499+
RemoteServerFactory._setupHybridProxy(mockApp, options)
500+
501+
expect(mockApp.use).toHaveBeenCalledWith(expect.any(Function))
502+
expect(mockApp.use).toHaveBeenCalledTimes(1)
503+
})
504+
505+
it('should call app.use when hybridProxy.enabled is truthy string', () => {
506+
const mockApp = {use: jest.fn()}
507+
const options = {
508+
localAllowCookies: true,
509+
hybridProxy: {
510+
enabled: 'true', // truthy string
511+
sfccOrigin: 'https://test.com',
512+
routingRules: ['http.request.uri.path eq "/test"']
513+
}
514+
}
515+
516+
RemoteServerFactory._setupHybridProxy(mockApp, options)
517+
518+
expect(mockApp.use).toHaveBeenCalledWith(expect.any(Function))
519+
expect(mockApp.use).toHaveBeenCalledTimes(1)
520+
})
521+
522+
it('should not call app.use when hybridProxy.enabled is falsy', () => {
523+
const mockApp = {use: jest.fn()}
524+
const falsyValues = [false, 0, '', null, undefined]
525+
526+
falsyValues.forEach((falsyValue) => {
527+
jest.clearAllMocks()
528+
const options = {
529+
hybridProxy: {
530+
enabled: falsyValue,
531+
sfccOrigin: 'https://test.com',
532+
routingRules: ['http.request.uri.path eq "/test"']
533+
}
534+
}
535+
536+
RemoteServerFactory._setupHybridProxy(mockApp, options)
537+
538+
expect(mockApp.use).not.toHaveBeenCalled()
539+
})
540+
})
541+
})

packages/pwa-kit-runtime/src/utils/logger-factory.test.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ describe('PWAKitLogger', () => {
7474
expect(console.info).toHaveBeenCalledWith('testNamespace INFO This is an info message')
7575
})
7676

77+
test('should handle log method (alias for info)', () => {
78+
const logger = createLogger({packageName: 'test-package'})
79+
logger.log('This is a log message')
80+
expect(console.info).toHaveBeenCalledWith('test-package INFO This is a log message')
81+
})
82+
7783
describe('serializeError method', () => {
7884
let logger
7985

0 commit comments

Comments
 (0)