From c021ffb44ad92c21c0834dd1f473d6a2fdb004ab Mon Sep 17 00:00:00 2001 From: Charles Hudson Date: Thu, 2 Apr 2026 12:25:19 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=A1=20refactor(stateless):=20Simplifyi?= =?UTF-8?q?ng=20stateless=20usage=20and=20updating=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [[NT-2817](https://contentful.atlassian.net/browse/NT-2817)] --- CONTRIBUTING.md | 24 +- .../integrating-the-node-sdk-in-a-node-app.md | 83 ++++--- implementations/node-sdk+web-sdk/README.md | 22 +- implementations/node-sdk+web-sdk/src/app.ts | 32 +-- implementations/node-sdk/README.md | 22 +- implementations/node-sdk/src/app.ts | 27 ++- implementations/web-sdk/README.md | 18 +- packages/node/node-sdk/README.md | 36 ++- packages/node/node-sdk/dev/server.ts | 5 +- .../src/ContentfulOptimization.test.ts | 5 +- .../node-sdk/src/ContentfulOptimization.ts | 8 +- packages/universal/core-sdk/README.md | 35 ++- .../core-sdk/src/CoreStateless.test.ts | 154 ++++++------ .../universal/core-sdk/src/CoreStateless.ts | 219 ++++++++++++++++-- .../core-sdk/src/CoreStatelessRequestScope.ts | 219 ------------------ packages/universal/core-sdk/src/index.ts | 1 - .../web/frameworks/react-web-sdk/AGENTS.md | 5 +- packages/web/web-sdk/AGENTS.md | 4 +- 18 files changed, 477 insertions(+), 442 deletions(-) delete mode 100644 packages/universal/core-sdk/src/CoreStatelessRequestScope.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 017d5536..3699a9bd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,7 @@ gotchas. The following software is required or strongly recommended for day-to-day development: - [Node.js](https://nodejs.org/) (use [`.nvmrc`](./.nvmrc) when possible; the minimum supported - version is `20.19.0`) + version is specified in [`package.json`](./package.json)) - [pnpm](https://pnpm.io/installation) (the pinned version is recorded in the root [`package.json`](./package.json)) - [`jq`](https://jqlang.org/) for the local `pre-push` hook @@ -68,8 +68,8 @@ The following software is required or strongly recommended for day-to-day develo > [!NOTE] > -> Browser and implementation-specific E2E flows also require Playwright browser binaries. The -> targeted `pnpm setup:e2e:` wrappers install them for you. +> Browser-based E2E flows also require Playwright browser binaries. The targeted +> `pnpm setup:e2e:` wrappers install them for browser implementations. After cloning the repository: @@ -220,7 +220,7 @@ pnpm implementation:run -- [args...] ``` - `` is a folder name under `implementations/` (for example: `web-sdk`, - `web-sdk_react`, `node-sdk`, `node-sdk+web-sdk`, `react-native-sdk`) + `web-sdk_react`, `react-web-sdk`, `node-sdk`, `node-sdk+web-sdk`, `react-native-sdk`) - `` can be one of these helper actions: - `implementation:install` - `implementation:build:run` @@ -360,14 +360,14 @@ This is an intentional CI policy: The path filters do not watch only implementation directories. Shared package and root changes can also trigger implementation E2E. At a high level: -| E2E job | Also watches shared surfaces | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `e2e_node_ssr_only` | `lib/**`, `packages/node/node-sdk/**`, universal packages, root package/workflow files | -| `e2e_node_ssr_web_vanilla` | `lib/**`, `packages/node/node-sdk/**`, `packages/web/web-sdk/**`, `packages/web/preview-panel/**`, shared root files | -| `e2e_web` | `lib/**`, `packages/web/web-sdk/**`, `packages/web/preview-panel/**`, universal packages, shared root files | -| `e2e_web_react` | `lib/**`, `packages/web/web-sdk/**`, `packages/web/preview-panel/**`, universal packages, shared root files | -| `e2e_react_web_sdk` | `lib/**`, `packages/web/frameworks/react-web-sdk/**`, `packages/web/web-sdk/**`, `packages/web/preview-panel/**`, universal packages, shared root files | -| `e2e_react_native_android` | `lib/**`, `packages/react-native-sdk/**`, universal packages, shared root files | +| Workflow filter -> job(s) | Also watches shared surfaces | +| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `e2e_node_sdk` -> `e2e-node-sdk` | `lib/**`, `packages/node/node-sdk/**`, universal packages, root package/workflow files | +| `e2e_node_sdk_web_sdk` -> `e2e-node-sdk-web-sdk` | `lib/**`, `packages/node/node-sdk/**`, `packages/web/web-sdk/**`, `packages/web/preview-panel/**`, shared root files | +| `e2e_web_sdk` -> `e2e-web-sdk` | `lib/**`, `packages/web/web-sdk/**`, `packages/web/preview-panel/**`, universal packages, shared root files | +| `e2e_web_sdk_react` -> `e2e-web-sdk_react` | `lib/**`, `packages/web/web-sdk/**`, `packages/web/preview-panel/**`, universal packages, shared root files | +| `e2e_react_web_sdk` -> `e2e-react-web-sdk` | `lib/**`, `packages/web/frameworks/react-web-sdk/**`, `packages/web/web-sdk/**`, `packages/web/preview-panel/**`, universal packages, shared root files | +| `e2e_react_native_android` -> `e2e-react-native-android-build`, `e2e-react-native-android` | `lib/**`, `packages/react-native-sdk/**`, universal packages, shared root files | See [`.github/workflows/main-pipeline.yaml`](./.github/workflows/main-pipeline.yaml) for the exact authoritative filter list. diff --git a/documentation/integrating-the-node-sdk-in-a-node-app.md b/documentation/integrating-the-node-sdk-in-a-node-app.md index cb19c627..ec44bace 100644 --- a/documentation/integrating-the-node-sdk-in-a-node-app.md +++ b/documentation/integrating-the-node-sdk-in-a-node-app.md @@ -82,8 +82,8 @@ export const optimization = new ContentfulOptimization({ ``` Treat that SDK as a module-level singleton for the current Node process. Do not create a new -`ContentfulOptimization` instance per incoming request. Create a fresh -`optimization.forRequest(...)` scope for each request instead. +`ContentfulOptimization` instance per incoming request. Instead, compute request-scoped Experience +options per request and pass them as the final argument to stateless event methods. Notes: @@ -177,6 +177,9 @@ One common conservative pattern is: - when consent is revoked, clear the stored anonymous ID and stop sending further optimization traffic until consent is granted again +If your app stores consent in cookies, register cookie parsing middleware before reading +`req.cookies`. The next section shows the same Express setup for profile persistence. + ```ts import type { Request, Response } from 'express' import { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-node/constants' @@ -266,22 +269,28 @@ app.get('/', async (req, res) => { } const requestContext = getRequestContext(req) - const requestOptimization = optimization.forRequest(getExperienceRequestOptions(req)) + const requestOptions = getExperienceRequestOptions(req) const existingProfile = getProfileFromRequest(req) - const pageResponse: OptimizationData | undefined = await requestOptimization.page({ - ...requestContext, - profile: existingProfile, - }) + const pageResponse: OptimizationData | undefined = await optimization.page( + { + ...requestContext, + profile: existingProfile, + }, + requestOptions, + ) const userId = getAuthenticatedUserId(req) const identifyResponse = userId - ? await requestOptimization.identify({ - ...requestContext, - profile: pageResponse?.profile ?? existingProfile, - userId, - traits: { authenticated: true }, - }) + ? await optimization.identify( + { + ...requestContext, + profile: pageResponse?.profile ?? existingProfile, + userId, + traits: { authenticated: true }, + }, + requestOptions, + ) : undefined if (consented) { @@ -358,12 +367,15 @@ async function getArticle(entryId: string): Promise { app.get('/article/:entryId', async (req, res) => { const consented = hasOptimizationConsent(req) - const requestOptimization = optimization.forRequest(getExperienceRequestOptions(req)) + const requestOptions = getExperienceRequestOptions(req) const pageResponse = consented - ? await requestOptimization.page({ - ...getRequestContext(req), - profile: getProfileFromRequest(req), - }) + ? await optimization.page( + { + ...getRequestContext(req), + profile: getProfileFromRequest(req), + }, + requestOptions, + ) : undefined const article = await getArticle(req.params.entryId) @@ -434,7 +446,7 @@ captured as an Insights event, call `trackFlagView()` explicitly: ```ts if (pageResponse?.profile) { - await requestOptimization.trackFlagView({ + await optimization.trackFlagView({ ...getRequestContext(req), componentId: 'new-navigation', profile: pageResponse.profile, @@ -462,17 +474,20 @@ profile. Example custom event: ```ts -const requestOptimization = optimization.forRequest(getExperienceRequestOptions(req)) +const requestOptions = getExperienceRequestOptions(req) -await requestOptimization.track({ - ...getRequestContext(req), - profile: pageResponse?.profile, - event: 'quote_requested', - properties: { - plan: 'enterprise', - source: 'pricing-page', +await optimization.track( + { + ...getRequestContext(req), + profile: pageResponse?.profile, + event: 'quote_requested', + properties: { + plan: 'enterprise', + source: 'pricing-page', + }, }, -}) + requestOptions, +) ``` Example rendered-entry view event: @@ -480,7 +495,7 @@ Example rendered-entry view event: ```ts import { randomUUID } from 'node:crypto' -const requestOptimization = optimization.forRequest(getExperienceRequestOptions(req)) +const requestOptions = getExperienceRequestOptions(req) const viewPayload = { ...getRequestContext(req), componentId: optimizedArticle.sys.id, @@ -491,15 +506,9 @@ const viewPayload = { } if (selectedOptimization?.sticky) { - await requestOptimization.trackView({ - ...viewPayload, - sticky: true, - }) + await optimization.trackView({ ...viewPayload, sticky: true }, requestOptions) } else if (pageResponse?.profile) { - await requestOptimization.trackView({ - ...viewPayload, - profile: pageResponse.profile, - }) + await optimization.trackView({ ...viewPayload, profile: pageResponse.profile }, requestOptions) } ``` diff --git a/implementations/node-sdk+web-sdk/README.md b/implementations/node-sdk+web-sdk/README.md index 27e2c890..2ffd3432 100644 --- a/implementations/node-sdk+web-sdk/README.md +++ b/implementations/node-sdk+web-sdk/README.md @@ -24,8 +24,8 @@ This is a reference implementation using both the [Optimization Web SDK](../../packages/web/web-sdk/README.md), and is part of the [Contentful Optimization SDK Suite](../../README.md). -On the server side, the stateless Node SDK is created once at module load and each request binds its -own request-scoped options through `forRequest(...)`. +On the server side, the stateless Node SDK is created once at module load and each request passes +its request-scoped options directly to stateless event methods. The goal of this reference implementation is to illustrate the usage of cookie-based communication in both the Node and Web SDKs, which is an important component of many server-side/client-side @@ -47,26 +47,32 @@ All steps should be run from the monorepo root. pnpm install ``` -2. Ensure the required packages can be built: +2. Build the local package tarballs consumed by implementations: ```sh - pnpm --stream build + pnpm build:pkgs ``` -3. Configure the environment in a `.env` file in `implementations/node-sdk+web-sdk` based on the +3. Install this implementation so its local `@contentful/*` dependencies resolve from `pkgs/`: + + ```sh + pnpm implementation:run -- node-sdk+web-sdk implementation:install + ``` + +4. Configure the environment in a `.env` file in `implementations/node-sdk+web-sdk` based on the `.env.example` included file. The file is pre-populated with values that are valid only against the mock server implementation. To test the implementation against a live server environment, see the [mocks package](../../lib/mocks/README.md) for information on how to set up Contentful space with test data. -4. Start the mock API and application servers: +5. Start the mock API and application servers: ```sh pnpm --dir implementations/node-sdk+web-sdk --ignore-workspace serve ``` -5. The application can be accessed via Web browser at `http://localhost:3000` +6. The application can be accessed via Web browser at `http://localhost:3000` -6. Stop the mock API and application servers: +7. Stop the mock API and application servers: ```sh pnpm --dir implementations/node-sdk+web-sdk --ignore-workspace serve:stop diff --git a/implementations/node-sdk+web-sdk/src/app.ts b/implementations/node-sdk+web-sdk/src/app.ts index aaf0e99a..0a8beddd 100644 --- a/implementations/node-sdk+web-sdk/src/app.ts +++ b/implementations/node-sdk+web-sdk/src/app.ts @@ -108,26 +108,32 @@ async function getProfile( anonymousId?: string, ): Promise { const args = getUniversalEventBuilderArgs(req) - const requestOptimization = sdk.forRequest({ + const requestOptions = { locale: args.locale, - }) + } const cookieProfile = anonymousId ? { id: anonymousId } : undefined if (!userId) { - return await requestOptimization.page({ ...args, profile: cookieProfile }) + return await sdk.page({ ...args, profile: cookieProfile }, requestOptions) } - const identifyResponse = await requestOptimization.identify({ - ...args, - userId, - profile: cookieProfile, - traits: { identified: true }, - }) + const identifyResponse = await sdk.identify( + { + ...args, + userId, + profile: cookieProfile, + traits: { identified: true }, + }, + requestOptions, + ) - return await requestOptimization.page({ - ...args, - profile: cookieProfile ?? { id: identifyResponse.profile.id }, - }) + return await sdk.page( + { + ...args, + profile: cookieProfile ?? { id: identifyResponse.profile.id }, + }, + requestOptions, + ) } app.get('/', limiter, async (req, res) => { diff --git a/implementations/node-sdk/README.md b/implementations/node-sdk/README.md index feaa9453..70efdb5b 100644 --- a/implementations/node-sdk/README.md +++ b/implementations/node-sdk/README.md @@ -23,8 +23,8 @@ This is a reference implementation for the [Optimization Node SDK](../../packages/node/node-sdk/README.md) and is part of the [Contentful Optimization SDK Suite](../../README.md). -The server creates one stateless Node SDK instance at module load and binds request-specific options -through `forRequest(...)` inside each incoming request handler. +The server creates one stateless Node SDK instance at module load and passes request-specific +options directly to stateless event methods inside each incoming request handler. > [!WARNING] > @@ -41,26 +41,32 @@ All steps should be run from the monorepo root. pnpm install ``` -2. Ensure the required packages can be built: +2. Build the local package tarballs consumed by implementations: ```sh - pnpm --stream build + pnpm build:pkgs ``` -3. Configure the environment in a `.env` file in `implementations/node-sdk` based on the +3. Install this implementation so its local `@contentful/*` dependencies resolve from `pkgs/`: + + ```sh + pnpm implementation:run -- node-sdk implementation:install + ``` + +4. Configure the environment in a `.env` file in `implementations/node-sdk` based on the `.env.example` included file. The file is pre-populated with values that are valid only against the mock server implementation. To test the implementation against a live server environment, see the [mocks package](../../lib/mocks/README.md) for information on how to set up Contentful space with test data. -4. Start the mock API and application servers: +5. Start the mock API and application servers: ```sh pnpm --dir implementations/node-sdk --ignore-workspace serve ``` -5. The application can be accessed via Web browser at `http://localhost:3000` +6. The application can be accessed via Web browser at `http://localhost:3000` -6. Stop the mock API and application servers: +7. Stop the mock API and application servers: ```sh pnpm --dir implementations/node-sdk --ignore-workspace serve:stop diff --git a/implementations/node-sdk/src/app.ts b/implementations/node-sdk/src/app.ts index c2c993d6..19656e63 100644 --- a/implementations/node-sdk/src/app.ts +++ b/implementations/node-sdk/src/app.ts @@ -149,27 +149,26 @@ function getUniversalEventBuilderArgs(req: Request): UniversalEventBuilderArgs { app.get('/', limiter, async (req, res) => { const universalEventBuilderArgs = getUniversalEventBuilderArgs(req) - const requestOptimization = sdk.forRequest({ + const requestOptions = { locale: universalEventBuilderArgs.locale, - }) + } const userId = isNonEmptyString(req.query.userId) ? req.query.userId.trim() : undefined const optimizationResponse: OptimizationData = isNonEmptyString(userId) ? await (async (): Promise => { - const pageResponse = await requestOptimization.page({ - ...universalEventBuilderArgs, - }) - return await requestOptimization.identify({ - ...universalEventBuilderArgs, - userId, - traits: { identified: true }, - profile: pageResponse.profile, - }) + const pageResponse = await sdk.page({ ...universalEventBuilderArgs }, requestOptions) + return await sdk.identify( + { + ...universalEventBuilderArgs, + userId, + traits: { identified: true }, + profile: pageResponse.profile, + }, + requestOptions, + ) })() - : await requestOptimization.page({ - ...universalEventBuilderArgs, - }) + : await sdk.page({ ...universalEventBuilderArgs }, requestOptions) const { profile, selectedOptimizations } = optimizationResponse diff --git a/implementations/web-sdk/README.md b/implementations/web-sdk/README.md index 6da09867..b0789b9c 100644 --- a/implementations/web-sdk/README.md +++ b/implementations/web-sdk/README.md @@ -33,26 +33,32 @@ All steps should be run from the monorepo root. pnpm install ``` -2. Ensure the required packages can be built: +2. Build the local package tarballs consumed by implementations: ```sh - pnpm --stream build + pnpm build:pkgs ``` -3. Configure the environment in a `.env` file in `implementations/web-sdk` based on the +3. Install this implementation so its local `@contentful/*` dependencies resolve from `pkgs/`: + + ```sh + pnpm implementation:run -- web-sdk implementation:install + ``` + +4. Configure the environment in a `.env` file in `implementations/web-sdk` based on the `.env.example` included file. The file is pre-populated with values that are valid only against the mock server implementation. To test the implementation against a live server environment, see the [mocks package](../../lib/mocks/README.md) for information on how to set up Contentful space with test data. -4. Start the mock API and application servers: +5. Start the mock API and application servers: ```sh pnpm --dir implementations/web-sdk --ignore-workspace serve ``` -5. The application can be accessed via Web browser at `http://localhost:3000` +6. The application can be accessed via Web browser at `http://localhost:3000` -6. Stop the mock API and application servers: +7. Stop the mock API and application servers: ```sh pnpm --dir implementations/web-sdk --ignore-workspace serve:stop diff --git a/packages/node/node-sdk/README.md b/packages/node/node-sdk/README.md index 5ca65b71..bfa26a10 100644 --- a/packages/node/node-sdk/README.md +++ b/packages/node/node-sdk/README.md @@ -72,11 +72,13 @@ Configure and initialize the Optimization Node SDK: ```ts const optimization = new ContentfulOptimization({ clientId: 'abc123' }) -const requestOptimization = optimization.forRequest() +const requestOptions = { locale: 'en-US' } + +await optimization.page({}, requestOptions) ``` -Create `optimization` once per module or process, then call `optimization.forRequest(...)` once per -incoming request. +Create `optimization` once per module or process, then pass request-scoped Experience options as the +final argument to stateless event methods inside each incoming request. ## Caching Guidance @@ -139,8 +141,8 @@ select less-common scenarios, with the most basic example solution possible. | `insightsBaseUrl` | No | `'https://ingest.insights.ninetailed.co/'` | Base URL for the Insights API | | `enabledFeatures` | No | `['ip-enrichment', 'location']` | Enabled features the Experience API may use for each request | -Request-scoped Experience API options are bound with `optimization.forRequest(...)` instead of the -SDK constructor: +Request-scoped Experience API options are passed to stateless event methods as their final argument +instead of the SDK constructor: | Option | Required? | Default | Description | | ----------- | --------- | ----------- | ------------------------------------------------------------------------------ | @@ -251,14 +253,20 @@ Arguments: ### Experience API and Insights API Event Methods -Create a request scope once per incoming request, then call event methods on that scope: +Pass request-scoped Experience options as the final argument to stateless event methods: ```ts -const requestOptimization = optimization.forRequest({ +const requestOptions = { locale: req.acceptsLanguages()[0] ?? 'en-US', -}) +} + +await optimization.page({ ...requestContext, profile }, requestOptions) ``` +Request-scoped Experience options stay separate from the event payload. Event context such as page +data, locale metadata on the event, and user agent belong in `payload`, while `ip`, `locale`, +`plainText`, and `preflight` belong in `requestOptions`. + Only the following methods may return an `OptimizationData` object: - `identify` @@ -287,6 +295,7 @@ Arguments: - `payload`\*: Identify event builder arguments object, including an optional `profile` property with a `PartialProfile` value that requires only an `id` +- `requestOptions`: Optional request-scoped Experience API options passed as the final argument #### `page` @@ -296,6 +305,7 @@ Arguments: - `payload`\*: Page view event builder arguments object, including an optional `profile` property with a `PartialProfile` value that requires only an `id` +- `requestOptions`: Optional request-scoped Experience API options passed as the final argument #### `screen` @@ -305,6 +315,7 @@ Arguments: - `payload`\*: Screen view event builder arguments object, including an optional `profile` property with a `PartialProfile` value that requires only an `id` +- `requestOptions`: Optional request-scoped Experience API options passed as the final argument #### `track` @@ -314,6 +325,7 @@ Arguments: - `payload`\*: Track event builder arguments object, including an optional `profile` property with a `PartialProfile` value that requires only an `id` +- `requestOptions`: Optional request-scoped Experience API options passed as the final argument #### `trackView` @@ -326,6 +338,8 @@ Arguments: - `payload`\*: Entry view event builder arguments object. When `payload.sticky` is `true`, `profile` is optional and the returned Experience profile is reused for Insights delivery. Otherwise, `profile` is required and must contain at least an `id` +- `requestOptions`: Optional request-scoped Experience API options passed as the final argument. + Only used when `payload.sticky` is `true` #### `trackClick` @@ -339,6 +353,8 @@ Arguments: - `payload`\*: Entry click event builder arguments object, including a required `profile` property with a `PartialProfile` value that requires only an `id` +- `requestOptions`: Optional request-scoped Experience API options accepted for signature + consistency; currently unused #### `trackHover` @@ -352,6 +368,8 @@ Arguments: - `payload`\*: Entry hover event builder arguments object, including a required `profile` property with a `PartialProfile` value that requires only an `id` +- `requestOptions`: Optional request-scoped Experience API options accepted for signature + consistency; currently unused #### `trackFlagView` @@ -366,6 +384,8 @@ Arguments: - `payload`\*: Flag view event builder arguments object, including a required `profile` property with a `PartialProfile` value that requires only an `id` +- `requestOptions`: Optional request-scoped Experience API options accepted for signature + consistency; currently unused ## Interceptors diff --git a/packages/node/node-sdk/dev/server.ts b/packages/node/node-sdk/dev/server.ts index c4b0e8cb..a22206b2 100644 --- a/packages/node/node-sdk/dev/server.ts +++ b/packages/node/node-sdk/dev/server.ts @@ -170,11 +170,10 @@ app.get('/', limiter, async (req, res) => { const requestProfile: PartialProfile | undefined = typeof profileId === 'string' ? { id: profileId } : undefined - const requestOptimization = sdk.forRequest() - let apiResponse: OptimizationData = await requestOptimization.page({ profile: requestProfile }) + let apiResponse: OptimizationData = await sdk.page({ profile: requestProfile }) if (isNonEmptyString(userId)) { - apiResponse = await requestOptimization.identify({ + apiResponse = await sdk.identify({ userId, profile: requestProfile, }) diff --git a/packages/node/node-sdk/src/ContentfulOptimization.test.ts b/packages/node/node-sdk/src/ContentfulOptimization.test.ts index e6890924..b50184f0 100644 --- a/packages/node/node-sdk/src/ContentfulOptimization.test.ts +++ b/packages/node/node-sdk/src/ContentfulOptimization.test.ts @@ -18,9 +18,8 @@ describe('ContentfulOptimization', () => { expect(node.eventBuilder.library.name).toEqual(OPTIMIZATION_NODE_SDK_NAME) }) - it('binds request-scoped event emitters through forRequest()', async () => { + it('forwards request options through direct stateless event methods', async () => { const node = new ContentfulOptimization(config) - const request = node.forRequest({ locale: 'de-DE', preflight: true }) const upsertProfile = rs.spyOn(node.api.experience, 'upsertProfile').mockResolvedValue({ changes: [], selectedOptimizations: [], @@ -49,7 +48,7 @@ describe('ContentfulOptimization', () => { }, }) - await request.page({ profile: { id: 'profile-id' } }) + await node.page({ profile: { id: 'profile-id' } }, { locale: 'de-DE', preflight: true }) expect(upsertProfile).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/node/node-sdk/src/ContentfulOptimization.ts b/packages/node/node-sdk/src/ContentfulOptimization.ts index ce56da92..0e516d99 100644 --- a/packages/node/node-sdk/src/ContentfulOptimization.ts +++ b/packages/node/node-sdk/src/ContentfulOptimization.ts @@ -1,8 +1,10 @@ -import { type CoreStatelessConfig, CoreStateless } from '@contentful/optimization-core' +import { CoreStateless } from '@contentful/optimization-core' import type { App } from '@contentful/optimization-core/api-schemas' import { merge } from 'es-toolkit' import { OPTIMIZATION_NODE_SDK_NAME, OPTIMIZATION_NODE_SDK_VERSION } from './constants' +type CoreStatelessConfig = ConstructorParameters[0] + /** * Configuration for the Node-specific ContentfulOptimization SDK. * @@ -80,9 +82,9 @@ function mergeConfig(config: OptimizationNodeConfig): CoreStatelessConfig { * logLevel: 'info', * }) * - * const request = sdk.forRequest({ locale: 'en-US' }) + * const requestOptions = { locale: 'en-US' } * - * await request.track({ event: 'server_event', properties: { id: 1 } }) + * await sdk.track({ event: 'server_event', properties: { id: 1 } }, requestOptions) * ``` * * @see {@link CoreStateless} diff --git a/packages/universal/core-sdk/README.md b/packages/universal/core-sdk/README.md index d780613d..e38e4d23 100644 --- a/packages/universal/core-sdk/README.md +++ b/packages/universal/core-sdk/README.md @@ -84,7 +84,9 @@ Configure and initialize the Core SDK: ```ts const statefulOptimization = new CoreStateful({ clientId: 'abc123' }) const statelessOptimization = new CoreStateless({ clientId: 'abc123' }) -const requestOptimization = statelessOptimization.forRequest() +const requestOptions = { locale: 'de-DE' } + +await statelessOptimization.page({}, requestOptions) ``` ## Working with Stateless Core @@ -97,8 +99,8 @@ In stateless environments, Core will not maintain any internal state, which incl These concerns should be handled by consumers to fit their specific architectural and design specifications. -Request-emitting methods in `CoreStateless` are bound per request via -`optimization.forRequest(...)`. +Request-scoped Experience options in `CoreStateless` are passed as the final argument to each +stateless event method. ## Working with Stateful Core @@ -164,8 +166,8 @@ Configuration method signatures: - `beaconHandler`: `(url: string | URL, data: BatchInsightsEventArray) => boolean` -In stateless environments, bind `ip`, `locale`, `plainText`, and `preflight` per request with -`optimization.forRequest(...)` instead of constructor config. +In stateless environments, pass `ip`, `locale`, `plainText`, and `preflight` as the final argument +to stateless event methods instead of constructor config. ### Queue Policy Options @@ -375,14 +377,19 @@ Arguments: ### Event Methods In `CoreStateful`, call these methods on the root instance. In `CoreStateless`, call them on the -request scope returned by `optimization.forRequest(...)`: +root instance and pass request-scoped Experience options as the final argument when needed: ```ts -const requestOptimization = optimization.forRequest({ +const requestOptions = { locale: 'de-DE', -}) +} + +await optimization.page({ ...requestContext, profile }, requestOptions) ``` +Request-scoped Experience options stay separate from the event payload. Event context data belongs +in `payload`, while `ip`, `locale`, `plainText`, and `preflight` belong in `requestOptions`. + Only the following methods may return an `OptimizationData` object: - `identify` @@ -411,6 +418,7 @@ Arguments: - `payload`\*: Identify event builder arguments object, including an optional `profile` property with a `PartialProfile` value that requires only an `id` +- `requestOptions`: Optional request-scoped Experience API options passed as the final argument #### `page` @@ -420,6 +428,7 @@ Arguments: - `payload`\*: Page view event builder arguments object, including an optional `profile` property with a `PartialProfile` value that requires only an `id` +- `requestOptions`: Optional request-scoped Experience API options passed as the final argument #### `screen` @@ -429,6 +438,7 @@ Arguments: - `payload`\*: Screen view event builder arguments object, including an optional `profile` property with a `PartialProfile` value that requires only an `id` +- `requestOptions`: Optional request-scoped Experience API options passed as the final argument #### `track` @@ -438,6 +448,7 @@ Arguments: - `payload`\*: Track event builder arguments object, including an optional `profile` property with a `PartialProfile` value that requires only an `id` +- `requestOptions`: Optional request-scoped Experience API options passed as the final argument #### `trackView` @@ -450,6 +461,8 @@ Arguments: - `payload`\*: Entry view event builder arguments object. When `payload.sticky` is `true`, `profile` is optional and the returned Experience profile is reused for Insights delivery. Otherwise, `profile` is required and must contain at least an `id` +- `requestOptions`: Optional request-scoped Experience API options passed as the final argument. + Only used when `payload.sticky` is `true` #### `trackClick` @@ -463,6 +476,8 @@ Arguments: - `payload`\*: Entry click event builder arguments object, including a required `profile` property with a `PartialProfile` value that requires only an `id` +- `requestOptions`: Optional request-scoped Experience API options accepted for signature + consistency; currently unused #### `trackHover` @@ -476,6 +491,8 @@ Arguments: - `payload`\*: Entry hover event builder arguments object, including a required `profile` property with a `PartialProfile` value that requires only an `id` +- `requestOptions`: Optional request-scoped Experience API options accepted for signature + consistency; currently unused #### `trackFlagView` @@ -490,6 +507,8 @@ Arguments: - `payload`\*: Flag view event builder arguments object, including a required `profile` property with a `PartialProfile` value that requires only an `id` +- `requestOptions`: Optional request-scoped Experience API options accepted for signature + consistency; currently unused ## Stateful-only Core Methods diff --git a/packages/universal/core-sdk/src/CoreStateless.test.ts b/packages/universal/core-sdk/src/CoreStateless.test.ts index 8e848a8f..fb913c91 100644 --- a/packages/universal/core-sdk/src/CoreStateless.test.ts +++ b/packages/universal/core-sdk/src/CoreStateless.test.ts @@ -2,15 +2,13 @@ import type { OptimizationData } from './api-schemas' import CoreStateless from './CoreStateless' const TRACK_CLICK_PROFILE_ERROR = - 'CoreStateless.forRequest().trackClick() requires `payload.profile.id` for Insights delivery.' + 'CoreStateless.trackClick() requires `payload.profile.id` for Insights delivery.' const TRACK_HOVER_PROFILE_ERROR = - 'CoreStateless.forRequest().trackHover() requires `payload.profile.id` for Insights delivery.' + 'CoreStateless.trackHover() requires `payload.profile.id` for Insights delivery.' const TRACK_FLAG_VIEW_PROFILE_ERROR = - 'CoreStateless.forRequest().trackFlagView() requires `payload.profile.id` for Insights delivery.' + 'CoreStateless.trackFlagView() requires `payload.profile.id` for Insights delivery.' const NON_STICKY_TRACK_VIEW_PROFILE_ERROR = - 'CoreStateless.forRequest().trackView() requires `payload.profile.id` when `payload.sticky` is not `true`.' - -type CoreStatelessRequest = ReturnType['forRequest']> + 'CoreStateless.trackView() requires `payload.profile.id` when `payload.sticky` is not `true`.' const EMPTY_OPTIMIZATION_DATA: OptimizationData = { changes: [], @@ -40,18 +38,18 @@ const EMPTY_OPTIMIZATION_DATA: OptimizationData = { }, } -async function invokeUntypedRequestMethod( - request: CoreStatelessRequest, +async function invokeUntypedMethod( + core: CoreStateless, method: 'trackClick' | 'trackHover' | 'trackFlagView' | 'trackView', payload: Record, ): Promise { - const methodRef = Reflect.get(request, method) + const methodRef = Reflect.get(core, method) if (typeof methodRef !== 'function') { throw new Error(`Expected "${method}" to be a function`) } - return await Reflect.apply(methodRef, request, [payload]) + return await Reflect.apply(methodRef, core, [payload]) } describe('CoreStateless', () => { @@ -85,32 +83,24 @@ describe('CoreStateless', () => { it('forwards request-bound options and explicit profiles through Experience upserts', async () => { const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) - const request = core.forRequest({ + const upsertProfile = rs + .spyOn(core.api.experience, 'upsertProfile') + .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + const requestOptions = { ip: '203.0.113.10', locale: 'de-DE', plainText: false, preflight: true, - }) - const upsertProfile = rs - .spyOn(core.api.experience, 'upsertProfile') - .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) + } - await request.identify({ - userId: 'user-123', - profile: { id: 'profile-123' }, - }) + await core.identify({ userId: 'user-123', profile: { id: 'profile-123' } }, requestOptions) expect(upsertProfile).toHaveBeenCalledWith( expect.objectContaining({ profileId: 'profile-123', events: [expect.objectContaining({ type: 'identify' })], }), - { - ip: '203.0.113.10', - locale: 'de-DE', - plainText: false, - preflight: true, - }, + requestOptions, ) }) @@ -119,12 +109,14 @@ describe('CoreStateless', () => { const upsertProfile = rs .spyOn(core.api.experience, 'upsertProfile') .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) - const request = core.forRequest({ locale: 'de-DE' }) - await request.page({ - locale: 'en-US', - profile: { id: 'profile-123' }, - }) + await core.page( + { + locale: 'en-US', + profile: { id: 'profile-123' }, + }, + { locale: 'de-DE' }, + ) expect(upsertProfile).toHaveBeenCalledWith( expect.objectContaining({ @@ -137,17 +129,15 @@ describe('CoreStateless', () => { ) }) - it('isolates request-bound options between separate request scopes', async () => { + it('isolates request-bound options between separate stateless calls', async () => { const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) const upsertProfile = rs .spyOn(core.api.experience, 'upsertProfile') .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) await Promise.all([ - core.forRequest({ ip: '203.0.113.10', locale: 'de-DE' }).track({ event: 'first' }), - core.forRequest({ ip: '198.51.100.5', locale: 'en-US', plainText: false }).track({ - event: 'second', - }), + core.track({ event: 'first' }, { ip: '203.0.113.10', locale: 'de-DE' }), + core.track({ event: 'second' }, { ip: '198.51.100.5', locale: 'en-US', plainText: false }), ]) const requestOptions = upsertProfile.mock.calls.map(([, options]) => options) @@ -166,19 +156,21 @@ describe('CoreStateless', () => { it('sends sticky entry views through both the Experience API and Insights API', async () => { const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) - const request = core.forRequest({ preflight: true }) const upsertProfile = rs .spyOn(core.api.experience, 'upsertProfile') .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) - await request.trackView({ - componentId: 'hero-banner', - sticky: true, - viewId: 'hero-banner-view', - viewDurationMs: 1000, - profile: { id: 'profile-123' }, - }) + await core.trackView( + { + componentId: 'hero-banner', + sticky: true, + viewId: 'hero-banner-view', + viewDurationMs: 1000, + profile: { id: 'profile-123' }, + }, + { preflight: true }, + ) expect(upsertProfile).toHaveBeenCalledWith( expect.objectContaining({ @@ -197,23 +189,22 @@ describe('CoreStateless', () => { it('rejects insights-only stateless methods without a profile id', async () => { const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) - const request = core.forRequest() const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) await expect( - invokeUntypedRequestMethod(request, 'trackClick', { + invokeUntypedMethod(core, 'trackClick', { componentId: 'hero-banner', }), ).rejects.toThrow(TRACK_CLICK_PROFILE_ERROR) await expect( - invokeUntypedRequestMethod(request, 'trackHover', { + invokeUntypedMethod(core, 'trackHover', { componentId: 'hero-banner', hoverDurationMs: 1000, hoverId: 'hover-id', }), ).rejects.toThrow(TRACK_HOVER_PROFILE_ERROR) await expect( - invokeUntypedRequestMethod(request, 'trackFlagView', { + invokeUntypedMethod(core, 'trackFlagView', { componentId: 'new-navigation', }), ).rejects.toThrow(TRACK_FLAG_VIEW_PROFILE_ERROR) @@ -223,19 +214,21 @@ describe('CoreStateless', () => { it('keeps non-sticky entry views on Insights only', async () => { const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) - const request = core.forRequest({ preflight: true }) const upsertProfile = rs .spyOn(core.api.experience, 'upsertProfile') .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) await expect( - request.trackView({ - componentId: 'hero-banner', - viewId: 'hero-banner-view', - viewDurationMs: 1000, - profile: { id: 'profile-123' }, - }), + core.trackView( + { + componentId: 'hero-banner', + viewId: 'hero-banner-view', + viewDurationMs: 1000, + profile: { id: 'profile-123' }, + }, + { preflight: true }, + ), ).resolves.toBeUndefined() expect(upsertProfile).not.toHaveBeenCalled() @@ -249,11 +242,10 @@ describe('CoreStateless', () => { it('rejects non-sticky entry views without a profile id', async () => { const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) - const request = core.forRequest({ preflight: true }) const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) await expect( - invokeUntypedRequestMethod(request, 'trackView', { + invokeUntypedMethod(core, 'trackView', { componentId: 'hero-banner', viewDurationMs: 1000, viewId: 'hero-banner-view', @@ -265,7 +257,6 @@ describe('CoreStateless', () => { it('reuses the Experience response profile for sticky entry views without an input profile', async () => { const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) - const request = core.forRequest({ preflight: true }) const responseProfile = { ...EMPTY_OPTIMIZATION_DATA.profile, id: 'profile-from-experience' } const upsertProfile = rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue({ ...EMPTY_OPTIMIZATION_DATA, @@ -273,12 +264,15 @@ describe('CoreStateless', () => { }) const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) - const result = await request.trackView({ - componentId: 'hero-banner', - sticky: true, - viewDurationMs: 1000, - viewId: 'hero-banner-view', - }) + const result = await core.trackView( + { + componentId: 'hero-banner', + sticky: true, + viewDurationMs: 1000, + viewId: 'hero-banner-view', + }, + { preflight: true }, + ) expect(result).toEqual({ ...EMPTY_OPTIMIZATION_DATA, @@ -301,7 +295,6 @@ describe('CoreStateless', () => { it('prefers the Experience response profile over a stale input profile for sticky entry views', async () => { const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) - const request = core.forRequest({ preflight: true }) const staleProfile = { id: 'stale-profile' } const responseProfile = { ...EMPTY_OPTIMIZATION_DATA.profile, id: 'fresh-profile' } rs.spyOn(core.api.experience, 'upsertProfile').mockResolvedValue({ @@ -310,13 +303,16 @@ describe('CoreStateless', () => { }) const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) - await request.trackView({ - componentId: 'hero-banner', - sticky: true, - viewDurationMs: 1000, - viewId: 'hero-banner-view', - profile: staleProfile, - }) + await core.trackView( + { + componentId: 'hero-banner', + sticky: true, + viewDurationMs: 1000, + viewId: 'hero-banner-view', + profile: staleProfile, + }, + { preflight: true }, + ) expect(sendBatchEvents).toHaveBeenCalledWith([ { @@ -328,18 +324,20 @@ describe('CoreStateless', () => { it('keeps request-bound options off insights-only methods', async () => { const core = new CoreStateless({ clientId: 'key_123', environment: 'main' }) - const request = core.forRequest({ - ip: '203.0.113.10', - locale: 'de-DE', - plainText: false, - preflight: true, - }) const upsertProfile = rs .spyOn(core.api.experience, 'upsertProfile') .mockResolvedValue(EMPTY_OPTIMIZATION_DATA) const sendBatchEvents = rs.spyOn(core.api.insights, 'sendBatchEvents').mockResolvedValue(true) - await request.trackClick({ componentId: 'hero-banner', profile: { id: 'profile-123' } }) + await core.trackClick( + { componentId: 'hero-banner', profile: { id: 'profile-123' } }, + { + ip: '203.0.113.10', + locale: 'de-DE', + plainText: false, + preflight: true, + }, + ) expect(upsertProfile).not.toHaveBeenCalled() expect(sendBatchEvents).toHaveBeenCalledWith([ diff --git a/packages/universal/core-sdk/src/CoreStateless.ts b/packages/universal/core-sdk/src/CoreStateless.ts index e4465657..1975cb6e 100644 --- a/packages/universal/core-sdk/src/CoreStateless.ts +++ b/packages/universal/core-sdk/src/CoreStateless.ts @@ -2,10 +2,28 @@ import type { ApiClientConfig, ExperienceApiClientRequestOptions, } from '@contentful/optimization-api-client' +import { + BatchInsightsEventArray, + ExperienceEvent as ExperienceEventSchema, + InsightsEvent as InsightsEventSchema, + parseWithFriendlyError, + type ExperienceEvent as ExperienceEventPayload, + type InsightsEvent as InsightsEventPayload, +} from '@contentful/optimization-api-client/api-schemas' import type { CoreStatelessApiConfig } from './CoreApiConfig' import CoreBase, { type CoreConfig } from './CoreBase' -import { CoreStatelessRequestScope } from './CoreStatelessRequestScope' -import type { EventBuilderConfig } from './events' +import { PartialProfile, type OptimizationData } from './api-schemas' +import type { + ClickBuilderArgs, + EventBuilderConfig, + FlagViewBuilderArgs, + HoverBuilderArgs, + IdentifyBuilderArgs, + PageViewBuilderArgs, + ScreenViewBuilderArgs, + TrackBuilderArgs, + ViewBuilderArgs, +} from './events' /** * Request-bound Experience API options for stateless runtimes. @@ -39,9 +57,40 @@ export interface CoreStatelessConfig extends CoreConfig { eventBuilder?: Omit } +type StatelessExperiencePayload = TPayload & { profile?: PartialProfile } +type StatelessInsightsPayload = TPayload & { profile: PartialProfile } +type StatelessStickyTrackViewPayload = ViewBuilderArgs & { + profile?: PartialProfile + sticky: true +} +type StatelessNonStickyTrackViewPayload = Omit & { + profile: PartialProfile + sticky?: false | undefined +} + +const TRACK_CLICK_PROFILE_ERROR = + 'CoreStateless.trackClick() requires `payload.profile.id` for Insights delivery.' +const TRACK_HOVER_PROFILE_ERROR = + 'CoreStateless.trackHover() requires `payload.profile.id` for Insights delivery.' +const TRACK_FLAG_VIEW_PROFILE_ERROR = + 'CoreStateless.trackFlagView() requires `payload.profile.id` for Insights delivery.' +const NON_STICKY_TRACK_VIEW_PROFILE_ERROR = + 'CoreStateless.trackView() requires `payload.profile.id` when `payload.sticky` is not `true`.' +const STICKY_TRACK_VIEW_PROFILE_ERROR = + 'CoreStateless.trackView() could not derive a profile from the sticky Experience response. Pass `payload.profile.id` explicitly if you need a fallback.' + const hasDefinedValues = (record: Record): boolean => Object.values(record).some((value) => value !== undefined) +const requireInsightsProfile = ( + profile: PartialProfile | undefined, + errorMessage: string, +): PartialProfile => { + if (profile !== undefined) return profile + + throw new Error(errorMessage) +} + const createStatelessExperienceApiConfig = ( api: CoreStatelessConfig['api'] | undefined, ): ApiClientConfig['experience'] => { @@ -69,12 +118,13 @@ const createStatelessInsightsApiConfig = ( * Core runtime for stateless environments. * * @public - * Built on top of `CoreBase`. Request-emitting methods are exposed through - * {@link CoreStateless.forRequest}. + * Built on top of `CoreBase`. Event-emitting methods are exposed directly on + * the stateless instance and accept request-scoped Experience options as a + * separate final argument. * @remarks - * The runtime itself is stateless, but request-scoped methods still perform - * outbound Experience and Insights API calls. Cache Contentful delivery data - * in the host application, not the results of those calls. + * The runtime itself is stateless, but event methods still perform outbound + * Experience and Insights API calls. Cache Contentful delivery data in the + * host application, not the results of those calls. */ class CoreStateless extends CoreBase { constructor(config: CoreStatelessConfig) { @@ -84,17 +134,152 @@ class CoreStateless extends CoreBase { }) } - /** - * Bind request-scoped Experience API options for a single stateless request. - * - * @param options - Request-scoped Experience API options. - * @returns A lightweight request scope for stateless event emission. - */ - forRequest(options: CoreStatelessRequestOptions = {}): CoreStatelessRequestScope { - return new CoreStatelessRequestScope(this, options) + async identify( + payload: StatelessExperiencePayload, + requestOptions?: CoreStatelessRequestOptions, + ): Promise { + const { profile, ...builderArgs } = payload + + return await this.sendExperienceEvent( + this.eventBuilder.buildIdentify(builderArgs), + profile, + requestOptions, + ) + } + + async page( + payload: StatelessExperiencePayload = {}, + requestOptions?: CoreStatelessRequestOptions, + ): Promise { + const { profile, ...builderArgs } = payload + + return await this.sendExperienceEvent( + this.eventBuilder.buildPageView(builderArgs), + profile, + requestOptions, + ) + } + + async screen( + payload: StatelessExperiencePayload, + requestOptions?: CoreStatelessRequestOptions, + ): Promise { + const { profile, ...builderArgs } = payload + + return await this.sendExperienceEvent( + this.eventBuilder.buildScreenView(builderArgs), + profile, + requestOptions, + ) + } + + async track( + payload: StatelessExperiencePayload, + requestOptions?: CoreStatelessRequestOptions, + ): Promise { + const { profile, ...builderArgs } = payload + + return await this.sendExperienceEvent( + this.eventBuilder.buildTrack(builderArgs), + profile, + requestOptions, + ) } -} -export { CoreStatelessRequestScope } + async trackView( + payload: StatelessStickyTrackViewPayload | StatelessNonStickyTrackViewPayload, + requestOptions?: CoreStatelessRequestOptions, + ): Promise { + const { profile, ...builderArgs } = payload + let result: OptimizationData | undefined = undefined + let insightsProfile: PartialProfile | undefined = profile + + if (payload.sticky) { + result = await this.sendExperienceEvent( + this.eventBuilder.buildView(builderArgs), + profile, + requestOptions, + ) + const { profile: responseProfile } = result + insightsProfile = responseProfile + } + + await this.sendInsightsEvent( + this.eventBuilder.buildView(builderArgs), + requireInsightsProfile( + insightsProfile, + payload.sticky ? STICKY_TRACK_VIEW_PROFILE_ERROR : NON_STICKY_TRACK_VIEW_PROFILE_ERROR, + ), + ) + + return result + } + + async trackClick( + payload: StatelessInsightsPayload, + _requestOptions?: CoreStatelessRequestOptions, + ): Promise { + const { profile, ...builderArgs } = payload + + await this.sendInsightsEvent( + this.eventBuilder.buildClick(builderArgs), + requireInsightsProfile(profile, TRACK_CLICK_PROFILE_ERROR), + ) + } + + async trackHover( + payload: StatelessInsightsPayload, + _requestOptions?: CoreStatelessRequestOptions, + ): Promise { + const { profile, ...builderArgs } = payload + + await this.sendInsightsEvent( + this.eventBuilder.buildHover(builderArgs), + requireInsightsProfile(profile, TRACK_HOVER_PROFILE_ERROR), + ) + } + + async trackFlagView( + payload: StatelessInsightsPayload, + _requestOptions?: CoreStatelessRequestOptions, + ): Promise { + const { profile, ...builderArgs } = payload + + await this.sendInsightsEvent( + this.eventBuilder.buildFlagView(builderArgs), + requireInsightsProfile(profile, TRACK_FLAG_VIEW_PROFILE_ERROR), + ) + } + + private async sendExperienceEvent( + event: ExperienceEventPayload, + profile?: PartialProfile, + requestOptions?: CoreStatelessRequestOptions, + ): Promise { + const intercepted = await this.interceptors.event.run(event) + const validEvent = parseWithFriendlyError(ExperienceEventSchema, intercepted) + + return await this.api.experience.upsertProfile( + { + profileId: profile?.id, + events: [validEvent], + }, + requestOptions, + ) + } + + private async sendInsightsEvent( + event: InsightsEventPayload, + profile: PartialProfile, + ): Promise { + const intercepted = await this.interceptors.event.run(event) + const validEvent = parseWithFriendlyError(InsightsEventSchema, intercepted) + const batchEvent: BatchInsightsEventArray = parseWithFriendlyError(BatchInsightsEventArray, [ + { profile: parseWithFriendlyError(PartialProfile, profile), events: [validEvent] }, + ]) + + await this.api.insights.sendBatchEvents(batchEvent) + } +} export default CoreStateless diff --git a/packages/universal/core-sdk/src/CoreStatelessRequestScope.ts b/packages/universal/core-sdk/src/CoreStatelessRequestScope.ts deleted file mode 100644 index 94a16450..00000000 --- a/packages/universal/core-sdk/src/CoreStatelessRequestScope.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { - BatchInsightsEventArray, - ExperienceEvent as ExperienceEventSchema, - InsightsEvent as InsightsEventSchema, - parseWithFriendlyError, - type ExperienceEvent as ExperienceEventPayload, - type InsightsEvent as InsightsEventPayload, -} from '@contentful/optimization-api-client/api-schemas' -import { PartialProfile, type OptimizationData } from './api-schemas' -import type CoreStateless from './CoreStateless' -import type { CoreStatelessRequestOptions } from './CoreStateless' -import type { - ClickBuilderArgs, - FlagViewBuilderArgs, - HoverBuilderArgs, - IdentifyBuilderArgs, - PageViewBuilderArgs, - ScreenViewBuilderArgs, - TrackBuilderArgs, - ViewBuilderArgs, -} from './events' - -type StatelessExperiencePayload = TPayload & { profile?: PartialProfile } -type StatelessInsightsPayload = TPayload & { profile: PartialProfile } -type StatelessStickyTrackViewPayload = ViewBuilderArgs & { - profile?: PartialProfile - sticky: true -} -type StatelessNonStickyTrackViewPayload = Omit & { - profile: PartialProfile - sticky?: false | undefined -} - -const TRACK_CLICK_PROFILE_ERROR = - 'CoreStateless.forRequest().trackClick() requires `payload.profile.id` for Insights delivery.' -const TRACK_HOVER_PROFILE_ERROR = - 'CoreStateless.forRequest().trackHover() requires `payload.profile.id` for Insights delivery.' -const TRACK_FLAG_VIEW_PROFILE_ERROR = - 'CoreStateless.forRequest().trackFlagView() requires `payload.profile.id` for Insights delivery.' -const NON_STICKY_TRACK_VIEW_PROFILE_ERROR = - 'CoreStateless.forRequest().trackView() requires `payload.profile.id` when `payload.sticky` is not `true`.' -const STICKY_TRACK_VIEW_PROFILE_ERROR = - 'CoreStateless.forRequest().trackView() could not derive a profile from the sticky Experience response. Pass `payload.profile.id` explicitly if you need a fallback.' - -const requireInsightsProfile = ( - profile: PartialProfile | undefined, - errorMessage: string, -): PartialProfile => { - if (profile !== undefined) return profile - - throw new Error(errorMessage) -} - -/** - * Stateless request scope created by {@link CoreStateless.forRequest}. - * - * @public - * @remarks - * Methods on this scope send outbound Experience or Insights API requests. - * Treat them as side-effecting per-request operations rather than cacheable - * reads. - */ -export class CoreStatelessRequestScope { - private readonly core: CoreStateless - private readonly options: Readonly - - constructor(core: CoreStateless, options: CoreStatelessRequestOptions = {}) { - this.core = core - this.options = Object.freeze({ ...options }) - } - - async identify( - payload: StatelessExperiencePayload, - ): Promise { - const { profile, ...builderArgs } = payload - - return await this.sendExperienceEvent( - this.core.eventBuilder.buildIdentify(builderArgs), - profile, - ) - } - - async page( - payload: StatelessExperiencePayload = {}, - ): Promise { - const { profile, ...builderArgs } = payload - - return await this.sendExperienceEvent( - this.core.eventBuilder.buildPageView(builderArgs), - profile, - ) - } - - async screen( - payload: StatelessExperiencePayload, - ): Promise { - const { profile, ...builderArgs } = payload - - return await this.sendExperienceEvent( - this.core.eventBuilder.buildScreenView(builderArgs), - profile, - ) - } - - async track(payload: StatelessExperiencePayload): Promise { - const { profile, ...builderArgs } = payload - - return await this.sendExperienceEvent(this.core.eventBuilder.buildTrack(builderArgs), profile) - } - - /** - * Record an entry view in a stateless runtime. - * - * @remarks - * Non-sticky entry views require `payload.profile.id` for Insights delivery. - * Sticky entry views may omit `profile`, because the returned Experience - * profile is reused for the paired Insights event. - */ - async trackView( - payload: StatelessStickyTrackViewPayload | StatelessNonStickyTrackViewPayload, - ): Promise { - const { profile, ...builderArgs } = payload - let result: OptimizationData | undefined = undefined - let insightsProfile: PartialProfile | undefined = profile - - if (payload.sticky) { - result = await this.sendExperienceEvent( - this.core.eventBuilder.buildView(builderArgs), - profile, - ) - const { profile: responseProfile } = result - insightsProfile = responseProfile - } - - await this.sendInsightsEvent( - this.core.eventBuilder.buildView(builderArgs), - requireInsightsProfile( - insightsProfile, - payload.sticky ? STICKY_TRACK_VIEW_PROFILE_ERROR : NON_STICKY_TRACK_VIEW_PROFILE_ERROR, - ), - ) - - return result - } - - /** - * Record an entry click in a stateless runtime. - * - * @remarks - * Stateless Insights delivery requires `payload.profile.id`. - */ - async trackClick(payload: StatelessInsightsPayload): Promise { - const { profile, ...builderArgs } = payload - - await this.sendInsightsEvent( - this.core.eventBuilder.buildClick(builderArgs), - requireInsightsProfile(profile, TRACK_CLICK_PROFILE_ERROR), - ) - } - - /** - * Record an entry hover in a stateless runtime. - * - * @remarks - * Stateless Insights delivery requires `payload.profile.id`. - */ - async trackHover(payload: StatelessInsightsPayload): Promise { - const { profile, ...builderArgs } = payload - - await this.sendInsightsEvent( - this.core.eventBuilder.buildHover(builderArgs), - requireInsightsProfile(profile, TRACK_HOVER_PROFILE_ERROR), - ) - } - - /** - * Record a Custom Flag view in a stateless runtime. - * - * @remarks - * Stateless Insights delivery requires `payload.profile.id`. - */ - async trackFlagView(payload: StatelessInsightsPayload): Promise { - const { profile, ...builderArgs } = payload - - await this.sendInsightsEvent( - this.core.eventBuilder.buildFlagView(builderArgs), - requireInsightsProfile(profile, TRACK_FLAG_VIEW_PROFILE_ERROR), - ) - } - - private async sendExperienceEvent( - event: ExperienceEventPayload, - profile?: PartialProfile, - ): Promise { - const intercepted = await this.core.interceptors.event.run(event) - const validEvent = parseWithFriendlyError(ExperienceEventSchema, intercepted) - - return await this.core.api.experience.upsertProfile( - { - profileId: profile?.id, - events: [validEvent], - }, - this.options, - ) - } - - private async sendInsightsEvent( - event: InsightsEventPayload, - profile: PartialProfile, - ): Promise { - const intercepted = await this.core.interceptors.event.run(event) - const validEvent = parseWithFriendlyError(InsightsEventSchema, intercepted) - const batchEvent: BatchInsightsEventArray = parseWithFriendlyError(BatchInsightsEventArray, [ - { profile: parseWithFriendlyError(PartialProfile, profile), events: [validEvent] }, - ]) - - await this.core.api.insights.sendBatchEvents(batchEvent) - } -} diff --git a/packages/universal/core-sdk/src/index.ts b/packages/universal/core-sdk/src/index.ts index 00f9c463..1ce73854 100644 --- a/packages/universal/core-sdk/src/index.ts +++ b/packages/universal/core-sdk/src/index.ts @@ -25,7 +25,6 @@ export type * from './CoreApiConfig' export * from './CoreBase' export * from './CoreStateful' export * from './CoreStateless' -export * from './CoreStatelessRequestScope' export * from './events' export * from './lib/decorators' export * from './lib/interceptor' diff --git a/packages/web/frameworks/react-web-sdk/AGENTS.md b/packages/web/frameworks/react-web-sdk/AGENTS.md index 97130f81..9f1f1556 100644 --- a/packages/web/frameworks/react-web-sdk/AGENTS.md +++ b/packages/web/frameworks/react-web-sdk/AGENTS.md @@ -16,7 +16,8 @@ providers, hooks, and React-facing entry resolution primitives. ## Local Rules -- Keep reusable React abstractions here rather than inside `implementations/web-sdk_react`. +- Keep reusable React abstractions here rather than inside `implementations/react-web-sdk` or + `implementations/web-sdk_react`. - Do not reimplement Web SDK core behavior here when it belongs in `packages/web/web-sdk`. - `dev/` is the package-local harness host shell. `dev/app/` contains the React harness app. - Keep both harness layers relevant to the current provider, hook, routing, and entry-rendering @@ -39,5 +40,5 @@ providers, hooks, and React-facing entry resolution primitives. - Run `typecheck`, `test:unit`, and `build`. - Run `size:check` for runtime, export, dependency, or bundle-shape changes. - Validate the `dev/` harness itself when changing package flows it is meant to demonstrate. -- Validate `implementations/web-sdk_react` Playwright flows when changing runtime behavior, +- Validate `implementations/react-web-sdk` Playwright flows when changing runtime behavior, readiness state, live updates, or entry rendering. diff --git a/packages/web/web-sdk/AGENTS.md b/packages/web/web-sdk/AGENTS.md index c80a2cf1..ca513b9b 100644 --- a/packages/web/web-sdk/AGENTS.md +++ b/packages/web/web-sdk/AGENTS.md @@ -41,5 +41,5 @@ interaction tracking. - Run `size:check` for runtime, dependency, or bundle-shape changes. - Validate the package-local `dev` flow itself when changing package flows it is meant to demonstrate. -- Validate `implementations/web-sdk`, `implementations/node-sdk+web-sdk`, or - `implementations/web-sdk_react` when browser behavior changed. +- Validate `implementations/web-sdk`, `implementations/node-sdk+web-sdk`, + `implementations/web-sdk_react`, or `implementations/react-web-sdk` when browser behavior changed.