diff --git a/packages/headless-react/src/ssr/search-engine.test.tsx b/packages/headless-react/src/ssr/search-engine.test.tsx index a5f7538ffb9..26ebd2ef3f1 100644 --- a/packages/headless-react/src/ssr/search-engine.test.tsx +++ b/packages/headless-react/src/ssr/search-engine.test.tsx @@ -12,6 +12,7 @@ import {defineSearchEngine} from './search-engine.js'; describe('Headless react SSR utils', () => { let errorSpy: jest.SpyInstance; + const mockedNavigatorContextProvider = jest.fn(); const sampleConfig = { ...getSampleSearchEngineConfiguration(), analytics: {enabled: false}, // TODO: KIT-2585 Remove after analytics SSR support is added @@ -34,6 +35,7 @@ describe('Headless react SSR utils', () => { controllers, StaticStateProvider, HydratedStateProvider, + setNavigatorContextProvider, ...rest } = defineSearchEngine({ configuration: sampleConfig, @@ -46,6 +48,7 @@ describe('Headless react SSR utils', () => { useEngine, StaticStateProvider, HydratedStateProvider, + setNavigatorContextProvider, ].forEach((returnValue) => expect(typeof returnValue).toBe('function')); expect(controllers).toEqual({}); @@ -81,6 +84,7 @@ describe('Headless react SSR utils', () => { StaticStateProvider, HydratedStateProvider, controllers, + setNavigatorContextProvider, useEngine, } = engineDefinition; @@ -131,6 +135,7 @@ describe('Headless react SSR utils', () => { }); test('should render with StaticStateProvider', async () => { + setNavigatorContextProvider(mockedNavigatorContextProvider); const staticState = await fetchStaticState(); render( @@ -142,6 +147,7 @@ describe('Headless react SSR utils', () => { }); test('should hydrate results with HydratedStateProvider', async () => { + setNavigatorContextProvider(mockedNavigatorContextProvider); const staticState = await fetchStaticState(); const {engine, controllers} = await hydrateStaticState(staticState); @@ -159,6 +165,7 @@ describe('Headless react SSR utils', () => { let hydratedState: InferHydratedState; beforeEach(async () => { + setNavigatorContextProvider(mockedNavigatorContextProvider); staticState = await fetchStaticState(); hydratedState = await hydrateStaticState(staticState); }); diff --git a/packages/headless/src/app/commerce-engine/commerce-engine.ssr.ts b/packages/headless/src/app/commerce-engine/commerce-engine.ssr.ts index 2f7e55e9283..73e9ae811d6 100644 --- a/packages/headless/src/app/commerce-engine/commerce-engine.ssr.ts +++ b/packages/headless/src/app/commerce-engine/commerce-engine.ssr.ts @@ -6,6 +6,7 @@ import {stateKey} from '../../app/state-key'; import {buildProductListing} from '../../controllers/commerce/product-listing/headless-product-listing'; import type {Controller} from '../../controllers/controller/headless-controller'; import {createWaitForActionMiddleware} from '../../utils/utils'; +import {NavigatorContextProvider} from '../navigatorContextProvider'; import { buildControllerDefinitions, composeFunction, @@ -117,13 +118,21 @@ export function defineCommerceEngine< type HydrateStaticStateFromBuildResultParameters = Parameters; - const getOpts = () => { + const getOptions = () => { return engineOptions; }; + const setNavigatorContextProvider = ( + navigatorContextProvider: NavigatorContextProvider + ) => { + engineOptions.navigatorContextProvider = navigatorContextProvider; + }; + const build: BuildFunction = async (...[buildOptions]: BuildParameters) => { const engine = buildSSRCommerceEngine( - buildOptions?.extend ? await buildOptions.extend(getOpts()) : getOpts() + buildOptions?.extend + ? await buildOptions.extend(getOptions()) + : getOptions() ); const controllers = buildControllerDefinitions({ definitionsMap: (controllerDefinitions ?? {}) as TControllerDefinitions, @@ -140,6 +149,12 @@ export function defineCommerceEngine< const fetchStaticState: FetchStaticStateFunction = composeFunction( async (...params: FetchStaticStateParameters) => { + if (!getOptions().navigatorContextProvider) { + // TODO: KIT-3409 - implement a logger to log SSR warnings/errors + console.warn( + '[WARNING] Missing navigator context in server-side code. Make sure to set it with `setNavigatorContextProvider` before calling fetchStaticState()' + ); + } const buildResult = await build(...params); const staticState = await fetchStaticState.fromBuildResult({ buildResult, @@ -170,6 +185,12 @@ export function defineCommerceEngine< const hydrateStaticState: HydrateStaticStateFunction = composeFunction( async (...params: HydrateStaticStateParameters) => { + if (!getOptions().navigatorContextProvider) { + // TODO: KIT-3409 - implement a logger to log SSR warnings/errors + console.warn( + '[WARNING] Missing navigator context in client-side code. Make sure to set it with `setNavigatorContextProvider` before calling hydrateStaticState()' + ); + } const buildResult = await build(...(params as BuildParameters)); const staticState = await hydrateStaticState.fromBuildResult({ buildResult, @@ -198,5 +219,6 @@ export function defineCommerceEngine< build, fetchStaticState, hydrateStaticState, + setNavigatorContextProvider, }; } diff --git a/packages/headless/src/app/search-engine/search-engine.ssr.ts b/packages/headless/src/app/search-engine/search-engine.ssr.ts index 35ad3cd17be..56003a36cca 100644 --- a/packages/headless/src/app/search-engine/search-engine.ssr.ts +++ b/packages/headless/src/app/search-engine/search-engine.ssr.ts @@ -5,6 +5,7 @@ import {UnknownAction} from '@reduxjs/toolkit'; import type {Controller} from '../../controllers/controller/headless-controller'; import {LegacySearchAction} from '../../features/analytics/analytics-utils'; import {createWaitForActionMiddleware} from '../../utils/utils'; +import {NavigatorContextProvider} from '../navigatorContextProvider'; import { buildControllerDefinitions, composeFunction, @@ -108,11 +109,21 @@ export function defineSearchEngine< type HydrateStaticStateFromBuildResultParameters = Parameters; + const getOptions = () => { + return engineOptions; + }; + + const setNavigatorContextProvider = ( + navigatorContextProvider: NavigatorContextProvider + ) => { + engineOptions.navigatorContextProvider = navigatorContextProvider; + }; + const build: BuildFunction = async (...[buildOptions]: BuildParameters) => { const engine = buildSSRSearchEngine( buildOptions?.extend - ? await buildOptions.extend(engineOptions) - : engineOptions + ? await buildOptions.extend(getOptions()) + : getOptions() ); const controllers = buildControllerDefinitions({ definitionsMap: (controllerDefinitions ?? {}) as TControllerDefinitions, @@ -129,6 +140,12 @@ export function defineSearchEngine< const fetchStaticState: FetchStaticStateFunction = composeFunction( async (...params: FetchStaticStateParameters) => { + if (!getOptions().navigatorContextProvider) { + // TODO: KIT-3409 - implement a logger to log SSR warnings/errors + console.warn( + '[WARNING] Missing navigator context in server-side code. Make sure to set it with `setNavigatorContextProvider` before calling fetchStaticState()' + ); + } const buildResult = await build(...params); const staticState = await fetchStaticState.fromBuildResult({ buildResult, @@ -156,6 +173,12 @@ export function defineSearchEngine< const hydrateStaticState: HydrateStaticStateFunction = composeFunction( async (...params: HydrateStaticStateParameters) => { + if (!getOptions().navigatorContextProvider) { + // TODO: KIT-3409 - implement a logger to log SSR warnings/errors + console.warn( + '[WARNING] Missing navigator context in client-side code. Make sure to set it with `setNavigatorContextProvider` before calling hydrateStaticState()' + ); + } const buildResult = await build(...(params as BuildParameters)); const staticState = await hydrateStaticState.fromBuildResult({ buildResult, @@ -184,5 +207,6 @@ export function defineSearchEngine< build, fetchStaticState, hydrateStaticState, + setNavigatorContextProvider, }; } diff --git a/packages/headless/src/app/ssr-engine/types/core-engine.ts b/packages/headless/src/app/ssr-engine/types/core-engine.ts index 2a688ea99ae..ffabf9c9766 100644 --- a/packages/headless/src/app/ssr-engine/types/core-engine.ts +++ b/packages/headless/src/app/ssr-engine/types/core-engine.ts @@ -2,6 +2,7 @@ import {AnyAction} from '@reduxjs/toolkit'; import type {Controller} from '../../../controllers/controller/headless-controller'; import {CoreEngine, CoreEngineNext} from '../../engine'; import {EngineConfiguration} from '../../engine-configuration'; +import {NavigatorContextProvider} from '../../navigatorContextProvider'; import {Build} from './build'; import { ControllerDefinitionsMap, @@ -49,6 +50,15 @@ export interface EngineDefinition< AnyAction, InferControllerPropsMapFromDefinitions >; + /** + * Sets the navigator context provider. + * This provider is essential for retrieving navigation-related data such as referrer, userAgent, location, and clientId, which are crucial for handling both server-side and client-side API requests effectively. + * + * Note: The implementation specifics of the navigator context provider depend on the Node.js framework being utilized. It is the developer's responsibility to appropriately define and implement the navigator context provider to ensure accurate navigation context is available throughout the application. If the user fails to provide a navigator context provider, a warning will be logged either on the server or the browser console. + */ + setNavigatorContextProvider: ( + navigatorContextProvider: NavigatorContextProvider + ) => void; /** * Builds an engine and its controllers from an engine definition. */ diff --git a/packages/headless/src/ssr-commerce.index.ts b/packages/headless/src/ssr-commerce.index.ts index 80c5b56748d..554c0c9f4bc 100644 --- a/packages/headless/src/ssr-commerce.index.ts +++ b/packages/headless/src/ssr-commerce.index.ts @@ -36,6 +36,7 @@ export type { InferBuildResult, } from './app/ssr-engine/types/core-engine'; export type {LoggerOptions} from './app/logger'; +export type {NavigatorContext} from './app/navigatorContextProvider'; export type {LogLevel} from './app/logger'; diff --git a/packages/samples/headless-ssr-commerce/app/_lib/navigatorContextProvider.ts b/packages/samples/headless-ssr-commerce/app/_lib/navigatorContextProvider.ts new file mode 100644 index 00000000000..cb5ffa03b7e --- /dev/null +++ b/packages/samples/headless-ssr-commerce/app/_lib/navigatorContextProvider.ts @@ -0,0 +1,63 @@ +import {NavigatorContext} from '@coveo/headless/ssr-commerce'; +import type {ReadonlyHeaders} from 'next/dist/server/web/spec-extension/adapters/headers'; + +/** + * This class implements the NavigatorContext interface from Coveo's SSR commerce sub-package. + * It is designed to work within a Next.js environment, providing a way to extract + * navigation-related context from Next.js request headers. This context will then be + * pass to subsequent search requests. + */ +export class NextJsNavigatorContext implements NavigatorContext { + /** + * Initializes a new instance of the NextJsNavigatorContext class. + * @param headers The readonly headers from a Next.js request, providing access to request-specific data. + */ + constructor(private headers: ReadonlyHeaders) {} + + /** + * Retrieves the referrer URL from the request headers. + * Some browsers use 'referer' while others may use 'referrer'. + * @returns The referrer URL if available, otherwise undefined. + */ + get referrer() { + return this.headers.get('referer') || this.headers.get('referrer'); + } + + /** + * Retrieves the user agent string from the request headers. + * @returns The user agent string if available, otherwise undefined. + */ + get userAgent() { + return this.headers.get('user-agent'); + } + + /** + * Placeholder for the location property. Needs to be implemented based on the application's requirements. + * @returns Currently returns a 'TODO:' string. + */ + get location() { + return 'TODO:'; + } + + /** + * Fetches the unique client ID that was generated earlier by the middleware. + * @returns The client ID. + */ + get clientId() { + const clientId = this.headers.get('x-coveo-client-id'); + return clientId!; + } + + /** + * Marshals the navigation context into a format that can be used by Coveo's headless library. + * @returns An object containing clientId, location, referrer, and userAgent properties. + */ + get marshal(): NavigatorContext { + return { + clientId: this.clientId, + location: this.location, + referrer: this.referrer, + userAgent: this.userAgent, + }; + } +} diff --git a/packages/samples/headless-ssr-commerce/app/middleware.ts b/packages/samples/headless-ssr-commerce/app/middleware.ts new file mode 100644 index 00000000000..297df469aef --- /dev/null +++ b/packages/samples/headless-ssr-commerce/app/middleware.ts @@ -0,0 +1,10 @@ +import {NextRequest, NextResponse} from 'next/server'; + +export default function middleware(request: NextRequest) { + const response = NextResponse.next(); + const requestHeaders = new Headers(request.headers); + const uuid = crypto.randomUUID(); + requestHeaders.set('x-coveo-client-id', uuid); + response.headers.set('x-coveo-client-id', uuid); + return response; +}