Skip to content

Commit

Permalink
feat(headless SSR): support navigator context in both Engine and fetc…
Browse files Browse the repository at this point in the history
…h/hydrate functions (#4231)

Introducing a method for configuring the navigator context provider.
Users are alerted with a warning if the navigator context is not
established on both the client and server sides.

I also updated the samples to demonstrate the navigator's application
within a Next.js framework, though the approach is not exclusive to this
framework.

There are two methods for setting the navigator context:
1. During engine configuration.
2. While initializing the app's static state and hydrating the app
state, if the first method is impractical.

The example in the SSR samples highlights a scenario where, due to the
framework's limitations, setting the navigator context directly within
the engine configuration is not feasible because headers are not yet
available. In such cases, the second method is recommended. However,
this approach necessitates invoking `setNavigatorContextProvider` on
both the server and client sides, increasing the potential for user
errors. To address this, a warning is issued if the setup is not
correctly implemented.
  • Loading branch information
y-lakhdar authored Aug 2, 2024
1 parent e8ebb09 commit 99bbef1
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 4 deletions.
7 changes: 7 additions & 0 deletions packages/headless-react/src/ssr/search-engine.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,6 +35,7 @@ describe('Headless react SSR utils', () => {
controllers,
StaticStateProvider,
HydratedStateProvider,
setNavigatorContextProvider,
...rest
} = defineSearchEngine({
configuration: sampleConfig,
Expand All @@ -46,6 +48,7 @@ describe('Headless react SSR utils', () => {
useEngine,
StaticStateProvider,
HydratedStateProvider,
setNavigatorContextProvider,
].forEach((returnValue) => expect(typeof returnValue).toBe('function'));

expect(controllers).toEqual({});
Expand Down Expand Up @@ -81,6 +84,7 @@ describe('Headless react SSR utils', () => {
StaticStateProvider,
HydratedStateProvider,
controllers,
setNavigatorContextProvider,
useEngine,
} = engineDefinition;

Expand Down Expand Up @@ -131,6 +135,7 @@ describe('Headless react SSR utils', () => {
});

test('should render with StaticStateProvider', async () => {
setNavigatorContextProvider(mockedNavigatorContextProvider);
const staticState = await fetchStaticState();
render(
<StaticStateProvider controllers={staticState.controllers}>
Expand All @@ -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);

Expand All @@ -159,6 +165,7 @@ describe('Headless react SSR utils', () => {
let hydratedState: InferHydratedState<typeof engineDefinition>;

beforeEach(async () => {
setNavigatorContextProvider(mockedNavigatorContextProvider);
staticState = await fetchStaticState();
hydratedState = await hydrateStaticState(staticState);
});
Expand Down
26 changes: 24 additions & 2 deletions packages/headless/src/app/commerce-engine/commerce-engine.ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -117,13 +118,21 @@ export function defineCommerceEngine<
type HydrateStaticStateFromBuildResultParameters =
Parameters<HydrateStaticStateFromBuildResultFunction>;

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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -198,5 +219,6 @@ export function defineCommerceEngine<
build,
fetchStaticState,
hydrateStaticState,
setNavigatorContextProvider,
};
}
28 changes: 26 additions & 2 deletions packages/headless/src/app/search-engine/search-engine.ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -108,11 +109,21 @@ export function defineSearchEngine<
type HydrateStaticStateFromBuildResultParameters =
Parameters<HydrateStaticStateFromBuildResultFunction>;

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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -184,5 +207,6 @@ export function defineSearchEngine<
build,
fetchStaticState,
hydrateStaticState,
setNavigatorContextProvider,
};
}
10 changes: 10 additions & 0 deletions packages/headless/src/app/ssr-engine/types/core-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -49,6 +50,15 @@ export interface EngineDefinition<
AnyAction,
InferControllerPropsMapFromDefinitions<TControllers>
>;
/**
* 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.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/headless/src/ssr-commerce.index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
10 changes: 10 additions & 0 deletions packages/samples/headless-ssr-commerce/app/middleware.ts
Original file line number Diff line number Diff line change
@@ -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;
}

0 comments on commit 99bbef1

Please sign in to comment.