diff --git a/.github/workflows/main-pipeline.yaml b/.github/workflows/main-pipeline.yaml index 369f3892..76246877 100644 --- a/.github/workflows/main-pipeline.yaml +++ b/.github/workflows/main-pipeline.yaml @@ -36,7 +36,7 @@ jobs: echo "VITE_CONTENTFUL_CDA_HOST=localhost:8000" >>.env echo "VITE_CONTENTFUL_BASE_PATH=contentful" >>.env - - run: cp .env implementations/node/ + - run: cp .env implementations/node-ssr/ - run: cp .env implementations/web-vanilla/ - uses: actions/cache@v4 @@ -44,7 +44,7 @@ jobs: with: path: | .env - implementations/node/.env + implementations/node-ssr/.env implementations/web-vanilla/.env key: ${{ runner.os }}-dotenv @@ -86,12 +86,10 @@ jobs: - if: steps.filter.outputs.lock == 'true' run: > - echo "In case of error, please see ./CONTRIBUTING.md" - pnpx license-checker \ - --summary \ - --production \ - --relativeLicensePath \ - --onlyAllow 'MIT;Apache-2.0;ISC;BSD-3-Clause;BSD-2-Clause;MIT*;Apache 2.0;Unlicense;Unlicensed;:CC0-1.0;CC-BY-4.0;WTFPL;0BSD;UNLICENSED;Python-2.0;MPL-2.0;CC-BY-3.0;CC0-1.0' + echo "In case of error, please see ./CONTRIBUTING.md" pnpx license-checker \ --summary \ + --production \ --relativeLicensePath \ --onlyAllow + 'MIT;Apache-2.0;ISC;BSD-3-Clause;BSD-2-Clause;MIT*;Apache + 2.0;Unlicense;Unlicensed;:CC0-1.0;CC-BY-4.0;WTFPL;0BSD;UNLICENSED;Python-2.0;MPL-2.0;CC-BY-3.0;CC0-1.0' format: name: Format Check 🎨 @@ -217,17 +215,17 @@ jobs: - uses: actions/checkout@v6 - run: | - echo "DOTENV_CONFIG_QUIET=true" >>implementations/node/.env - echo "VITE_NINETAILED_CLIENT_ID=${{secrets.NINETAILED_CLIENT_ID}}" >>implementations/node/.env - echo "VITE_NINETAILED_ENVIRONMENT=${{secrets.NINETAILED_ENVIRONMENT}}" >>implementations/node/.env - echo "VITE_EXPERIENCE_API_BASE_URL=http://localhost:8000/experience/" >>implementations/node/.env - echo "VITE_INSIGHTS_API_BASE_URL=http://localhost:8000/insights/" >>implementations/node/.env - echo "VITE_CONTENTFUL_TOKEN=${{secrets.CONTENTFUL_TOKEN}}" >>implementations/node/.env - echo "VITE_CONTENTFUL_PREVIEW_TOKEN=${{secrets.CONTENTFUL_PREVIEW_TOKEN}}" >>implementations/node/.env - echo "VITE_CONTENTFUL_ENVIRONMENT=${{secrets.CONTENTFUL_ENVIRONMENT}}" >>implementations/node/.env - echo "VITE_CONTENTFUL_SPACE_ID=${{secrets.CONTENTFUL_SPACE_ID}}" >>implementations/node/.env - echo "VITE_CONTENTFUL_CDA_HOST=localhost:8000" >>implementations/node/.env - echo "VITE_CONTENTFUL_BASE_PATH=contentful" >>implementations/node/.env + echo "DOTENV_CONFIG_QUIET=true" >>implementations/node-ssr/.env + echo "VITE_NINETAILED_CLIENT_ID=${{secrets.NINETAILED_CLIENT_ID}}" >>implementations/node-ssr/.env + echo "VITE_NINETAILED_ENVIRONMENT=${{secrets.NINETAILED_ENVIRONMENT}}" >>implementations/node-ssr/.env + echo "VITE_EXPERIENCE_API_BASE_URL=http://localhost:8000/experience/" >>implementations/node-ssr/.env + echo "VITE_INSIGHTS_API_BASE_URL=http://localhost:8000/insights/" >>implementations/node-ssr/.env + echo "VITE_CONTENTFUL_TOKEN=${{secrets.CONTENTFUL_TOKEN}}" >>implementations/node-ssr/.env + echo "VITE_CONTENTFUL_PREVIEW_TOKEN=${{secrets.CONTENTFUL_PREVIEW_TOKEN}}" >>implementations/node-ssr/.env + echo "VITE_CONTENTFUL_ENVIRONMENT=${{secrets.CONTENTFUL_ENVIRONMENT}}" >>implementations/node-ssr/.env + echo "VITE_CONTENTFUL_SPACE_ID=${{secrets.CONTENTFUL_SPACE_ID}}" >>implementations/node-ssr/.env + echo "VITE_CONTENTFUL_CDA_HOST=localhost:8000" >>implementations/node-ssr/.env + echo "VITE_CONTENTFUL_BASE_PATH=contentful" >>implementations/node-ssr/.env - uses: pnpm/action-setup@v4 @@ -251,8 +249,8 @@ jobs: with: name: ci-results-node path: | - ./implementations/node/playwright-report/ - ./implementations/node/test-results/ + ./implementations/node-ssr/playwright-report/ + ./implementations/node-ssr/test-results/ retention-days: 1 e2e-web: @@ -280,7 +278,7 @@ jobs: with: path: | .env - implementations/node/.env + implementations/node-ssr/.env implementations/web-vanilla/.env key: ${{ runner.os }}-dotenv @@ -393,7 +391,10 @@ jobs: ~/.gradle/caches ~/.gradle/wrapper implementations/react-native/android/.gradle - key: ${{ runner.os }}-gradle-${{ hashFiles('implementations/react-native/android/**/*.gradle*', 'implementations/react-native/android/gradle/wrapper/gradle-wrapper.properties') }} + key: + ${{ runner.os }}-gradle-${{ + hashFiles('implementations/react-native/android/**/*.gradle*', + 'implementations/react-native/android/gradle/wrapper/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- @@ -405,7 +406,8 @@ jobs: target: google_apis avd-name: test force-avd-creation: true - emulator-options: -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect -no-snapshot-save + emulator-options: + -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect -no-snapshot-save disable-animations: true emulator-boot-timeout: 300 disk-size: 6G @@ -423,7 +425,7 @@ jobs: - name: Upload Metro logs on failure if: failure() run: | - echo "=== Metro Bundler Logs ===" + echo "=== Metro Bundler Logs ===" cat /tmp/metro.log || echo "No metro logs found" echo "=== Mock Server Logs ===" cat /tmp/mock-server.log || echo "No mock server logs found" diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 00000000..a3dffb5c --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,18 @@ +MD013: + line_length: 100 + tables: false +MD024: + siblings_only: true +MD033: + allowed_elements: + - a + - details + - div + - h1 + - h2 + - h3 + - img + - p + - summary +MD041: + allow_preamble: true diff --git a/.prettierrc b/.prettierrc index 947d4771..47c515a5 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,7 @@ { "plugins": ["prettier-plugin-organize-imports"], "printWidth": 100, + "proseWrap": "always", "singleQuote": true, "semi": false } diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 852d2031..00000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,35 +0,0 @@ -# Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment include: - -- Using welcoming and inclusive language -- Being respectful of differing viewpoints and experiences -- Gracefully accepting constructive criticism -- Focusing on what is best for the community -- Showing empathy towards other community members - -Examples of unacceptable behavior include: - -- The use of sexualized language or imagery and unwelcome sexual attention or advances -- Trolling, insulting/derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information without explicit permission -- Other conduct which could reasonably be considered inappropriate in a professional setting - -## Enforcements - -Violations of these rules will not be tolerated and may result in a ban from the project, without warning and at the discretion of the project maintainers. - -Anyone asked to stop unacceptable behavior is expected to comply immediately. - -## Reporting - -If you are subject to or witness unacceptable behavior, or have any other concerns, please notify us by opening an issue or reaching us on the slack community. - -We will review and investigate all complaints, and will respond in a way that we deem appropriate to the circumstances. We are committed to maintaining confidentiality with regard to the reporter of an incident. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08e02d77..2fb2fbe4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,143 @@ -# Contributing to the Contentful Optimization SDK Suite +

+ + Contentful Logo + +

-[TODO] +

Contentful Personalization & Analytics

-## CI Issues +

Contributing

-#### License Check Failure +
+ +[Readme](./README.md) · [Reference](https://contentful.github.io/optimization) · +[Contributing](./CONTRIBUTING.md) + +
+ +We appreciate any community contributions to this project, whether in the form of issues or pull +requests. + +This document outlines what we'd like you to follow in terms of commit messages and code style. + +It also explains what to do in case you want to set up the project locally and run tests. + +**Working on your first Pull Request?** You can learn how from this extensive +[list of resources for people who are new to contributing to Open Source](https://github.com/freeCodeCamp/how-to-contribute-to-open-source). + +
+ Table of Contents + + +- [Setup](#setup) +- [Useful Scripts](#useful-scripts) +- [Code Style](#code-style) +- [Documentation](#documentation) +- [Troubleshooting CI Issues](#troubleshooting-ci-issues) + - [License Check Failure](#license-check-failure) + + +
+ +## Setup + +The following software is required for testing and maintaining Optimization SDK Suite packages: + +- [pnpm](https://pnpm.io/installation) +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) or any Docker-compatible + container manager + +> [!NOTE] +> +> Docker is currently only used to run E2E tests, specifically for the Web Vanilla reference +> implementation + +## Useful Scripts + +Code formatting: + +```sh +pnpm format:check +pnpm format:fix +``` + +Code linting: + +```sh +pnpm lint:check +pnpm link:fix +``` + +Check types: + +```sh +pnpm typecheck +``` + +Build all packages: + +```sh +pnpm build +``` + +Run unit tests: + +```sh +pnpm test:unit +``` + +Run E2E tests: + +```sh +pnpm test:e2e +``` + +Manage processes (useful when running reference implementations and their E2E tests): + +```sh +pnpm pm2:list +pnpm pm2:logs +pnpm pm2:stop:all +pnpm pm2:delete:all +``` + +Clean up all build artifacts: + +```sh +pnpm clean +``` + +Run any command for a specific package (example): + +```sh +pnpm --filter @contentful/optimization-web dev +``` + +## Code Style + +This project uses [ESLint](https://eslint.org/) and [Prettier](https://prettier.io/) to enforce +coding and formatting conventions. It may be useful to enable related editor plugins to have a +smoother experience when working on Optimization SDKs. + +Please review the following files to familiarize yourself with current configurations: + +- [eslint.config.ts](./eslint.config.ts) +- [.prettierrc](./.prettierrc) +- [.markdownlint.yaml](./.markdownlint.yaml) + +## Documentation + +Code is documented using TSDoc, and reference documentation is generated using TypeDoc and published +automatically with each new version. + +- `pnpm docs:generate` generates documentation from TSDoc code comments, as well as README and other + linked markdown files +- `pnpm docs:watch` watches for file updates and rebuilds documentation output; useful while writing + and updating documentation + +## Troubleshooting CI Issues + +### License Check Failure Run `licence-check` locally: @@ -13,4 +146,6 @@ pnpx license-check --summary pnpx license-check > licenses.txt ``` -If the license for a package merely has a spelling or formatting difference from an existing entry in the `license-check` GitHub workflow allow list, update the list and submit the change via pull request. Otherwise, create an issue to receive further guidance from the maintainers. +If the license for a package merely has a spelling or formatting difference from an existing entry +in the `license-check` GitHub workflow allow list, update the list and submit the change via pull +request. Otherwise, create an issue to receive further guidance from the maintainers. diff --git a/README.md b/README.md index f2c086d3..fdf571b8 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,121 @@ -# Contentful Optimization SDK Suite +

+ + Contentful Logo + +

+ +

Contentful Personalization & Analytics

+ +

Optimization SDK Suite

+ +
+ +[Readme](./README.md) · [Reference](https://contentful.github.io/optimization) · +[Contributing](./CONTRIBUTING.md) + +
+ +## Introduction + +A [pnpm](https://pnpm.io/) monorepo hosting a Suite of SDKs that implement functionality for +Contentful's [Personalization](https://www.contentful.com/developers/docs/personalization/) and +[Analytics](https://www.contentful.com/developers/docs/analytics/overview/) products. + +**What is Contentful?** + +Contentful provides content infrastructure for digital teams to power websites, apps, and devices. +Unlike a CMS, Contentful was built to integrate with the modern software stack. It offers a central +hub for structured content, powerful management and delivery APIs, and a customizable web app that +enables developers and content creators to ship their products faster. + +
+ Table of Contents + + +- [Optimization SDKs](#optimization-sdks) +- [Reference Implementations](#reference-implementations) +- [Universal Libraries](#universal-libraries) +- [Shared Internal Libraries](#shared-internal-libraries) +- [Get Involved](#get-involved) +- [License](#license) +- [Code of Conduct](#code-of-conduct) + + +
+ +## Optimization SDKs + +Optimization SDKs are organized in a hierarchy based on platform, environment, and optionally, +framework. Each SDK builds on top of the common SDK for its platform, environment, and so on, to +ensure functionality is reasonably shared and consistent. + +- [Optimization Core SDK](./universal/core/README.md) + - iOS + - iOS Swift SDK (TBD) + - Android + - Android Kotlin SDK (TBD) + - Android Java SDK (TBD) + - _JavaScript_ + - [Node SDK](/platforms/javascript/node/README.md) + - Nest.js SDK (TBD) + - [React Native SDK](/platforms/javascript/react-native/README.md) + - [Web SDK](/platforms/javascript/web/README.md) + - Angular SDK (TBD) + - React SDK (TBD) + - Svelte SDK (TBD) + - Vue SDK (TBD) + +> [!NOTE] +> +> The JavaScript platform includes React Native, which could be considered a JavaScript development +> environment for native platforms. -The Optimization SDK suite implements functionality for Contentful's Personalization and Analytics products. - -## Optimization Core Library - -The [Optimization Core SDK](./platforms/javascript/core/README.md) includes the following general features: - -- API adapters -- Universal logic and utilities -- Abstract classes and building blocks for platform- and framework-specific integrations - -The Core SDK is written in Typescript and compiled to Javascript - -## Javascript Platform - -The Optimization SDK suite supports three Javascript environments: +## Reference Implementations -- [Node](./platforms/javascript/node/README.md) (TBC) -- [Web](./platforms/javascript/web/README.md) (TBC) -- React Native (TBC) +The SDK Suite's reference implementations are intended to serve the following purposes: -Mobile applications that heavily utilize Web technologies, such as those built using Tauri or Ionic, may be able to use the Web Optimization SDK. +- E2E testing of critical SDK functionality +- Clear and simple documentation of common SDK use cases with absolutely no extraneous content or + functionality -### Frameworks +> [!WARNING] +> +> Reference implementations may share some similarities with projects commonly labeled examples, +> demos, or playgrounds, but are more sparse and strictly maintained -Framework-specific SDKs will be provided for the following popular frameworks: +## Universal Libraries -- Angular (TBC) -- NestJS (TBC) -- React (TBC) -- Svelte (TBC) -- Vue (TBC) +These libraries may be used independently of other libraries and SDKs in the Optimization SDK Suite. +They are relied upon by all SDKs, with their exported values and functionality exposed throughout +the SDK hierarchy. -There may also be SDKs provided for various meta-frameworks and/or hybrid frameworks, such as: +- [API Client Library](./universal/api-client/README.md) for the Experience & Insights APIs +- [API Schemas Library](./universal/api-schemas/README.md) maintains Zod validation schemas and + TypeScript types for working with Experience API request and response payloads -- Meteor (TBC) -- Next.js (TBC) -- Nuxt (TBC) -- SvelteKit (TBC) +## Shared Internal Libraries -Not every framework or architecture can be directly supported by a dedicated SDK. However, we will attempt to cover other integration possibilities via documentation. +Libraries that are shared internally among Optimization SDKs, and are not currently published, are +located within the `/lib` folder in the project root. -## Native Platforms +- [Logger](/lib/logger/README.md) is a simple logging abstraction utility +- [Mocks](/lib/mocks/README.md) supplies testing fixtures and data, as well as mock server + implementations used in both unit and end to end tests throughout the SDK suite -The Optimization SDK suite will support the following native platforms: +## Get Involved -- Android (TBC) -- iOS (TBC) +We appreciate any help on our repositories. For more details about how to contribute see our +[CONTRIBUTING](https://github.com/contentful/contentful.js/blob/master/CONTRIBUTING.md) document. -SDKs for platforms that are not Javascript-based may utilize Core within a Javascript context, to avoid duplication of logic. +## License -## Reference Implementations +This repository is published under the [MIT](LICENSE) license. -At least one reference implementation is provided for each of the suite's SDKs. These implementations are primarily intended to be used as documentation and for automated E2E testing. As such, there may be multiple reference implementations to cover various common application architecture possibilities where the differences are significant to the integration of the relevant SDK(s). +## Code of Conduct -## Additional Documentation +We want to provide a safe, inclusive, welcoming, and harassment-free space and experience for all +participants, regardless of gender identity and expression, sexual orientation, disability, physical +appearance, socioeconomic status, body size, ethnicity, nationality, level of experience, age, +religion (or lack thereof), or other identity markers. -Lower-level SDK documentation may be found at [https://contentful.github.io/optimization](https://contentful.github.io/optimization) +[Read our full Code of Conduct](https://www.contentful.com/developers/code-of-conduct/). diff --git a/contentful-icon.png b/contentful-icon.png new file mode 100644 index 00000000..c1361790 Binary files /dev/null and b/contentful-icon.png differ diff --git a/implementations/node/.env.example b/implementations/node-esr/.env.example similarity index 100% rename from implementations/node/.env.example rename to implementations/node-esr/.env.example diff --git a/implementations/node-esr/README.md b/implementations/node-esr/README.md new file mode 100644 index 00000000..0b4fb530 --- /dev/null +++ b/implementations/node-esr/README.md @@ -0,0 +1,5 @@ +# Contentful Optimization Node.JS Node SSR SDK Reference Implementation + +This is a reference implementation for the +[Optimization Node.JS Node SSR SDK](../../platforms/javascript/node/README.md) and is part of the +[Contentful Optimization SDK Suite](../../README.md). diff --git a/implementations/node-ssr/e2e/example.spec.ts b/implementations/node-esr/e2e/example.spec.ts similarity index 100% rename from implementations/node-ssr/e2e/example.spec.ts rename to implementations/node-esr/e2e/example.spec.ts diff --git a/implementations/node-esr/package.json b/implementations/node-esr/package.json new file mode 100644 index 00000000..3f9c1165 --- /dev/null +++ b/implementations/node-esr/package.json @@ -0,0 +1,50 @@ +{ + "private": true, + "name": "@implementation/node-esr", + "description": "Reference implementation for NodeJS Web projects", + "license": "MIT", + "main": "dist/app.js", + "types": "dist/app.d.ts", + "scripts": { + "build": "pnpm clean; pnpm build:sdk", + "build:sdk": "pnpm --filter '../../platforms/javascript/(api-schemas|api-client|core|web)' build && mkdir -p ./public/dist && cp -r ../../platforms/javascript/web/dist ./public", + "clean": "rimraf ./dist ./public/dist ./coverage ./playwright-report ./test-results tsconfig.tsbuildinfo", + "serve": "pnpm serve:mocks && pnpm serve:app", + "serve:app": "pnpm build && pm2 start --name ssr-app \"tsx --env-file=.env ./src/app.ts\"", + "serve:app:stop": "pm2 stop ssr-app && pm2 delete ssr-app", + "serve:mocks": "pm2 start --name ssr-mocks \"pnpm --filter mocks serve\"", + "serve:mocks:stop": "pm2 stop ssr-mocks && pm2 delete ssr-mocks", + "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", + "test:e2e": "pnpm serve && playwright test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", + "test:e2e:codegen": "playwright codegen", + "test:e2e:report": "playwright show-report", + "test:e2e:ui": "playwright test --ui", + "test:unit": "vitest run --coverage", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@contentful/optimization-node": "workspace:*", + "@contentful/optimization-web": "workspace:*", + "cookie-parser": "^1.4.7", + "express": "catalog:", + "express-rate-limit": "catalog:", + "qs": "catalog:", + "tslib": "catalog:" + }, + "devDependencies": { + "@playwright/test": "catalog:", + "@types/express": "catalog:", + "@types/node": "catalog:", + "@types/qs": "catalog:", + "@types/supertest": "catalog:", + "@vitest/coverage-v8": "catalog:", + "@types/cookie-parser": "1.4.7", + "dotenv": "catalog:", + "pm2": "catalog:", + "rimraf": "catalog:", + "supertest": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/implementations/node/playwright.config.mjs b/implementations/node-esr/playwright.config.mjs similarity index 95% rename from implementations/node/playwright.config.mjs rename to implementations/node-esr/playwright.config.mjs index 2bb70679..ecbcccc2 100644 --- a/implementations/node/playwright.config.mjs +++ b/implementations/node-esr/playwright.config.mjs @@ -31,9 +31,6 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', - - /* Record video only when retrying a test for the first time. */ - video: 'on-first-retry', }, /* Configure projects for major browsers */ diff --git a/implementations/node-ssr/src/app.test.ts b/implementations/node-esr/src/app.test.ts similarity index 100% rename from implementations/node-ssr/src/app.test.ts rename to implementations/node-esr/src/app.test.ts diff --git a/implementations/node-esr/src/app.ts b/implementations/node-esr/src/app.ts new file mode 100644 index 00000000..7f1524ab --- /dev/null +++ b/implementations/node-esr/src/app.ts @@ -0,0 +1,170 @@ +import Optimization, { + ANONYMOUS_ID_COOKIE, + type UniversalEventBuilderArgs, +} from '@contentful/optimization-node' +import cookieParser from 'cookie-parser' +import express, { type Express, type Request, type Response } from 'express' +import rateLimit from 'express-rate-limit' +import type { ParsedQs } from 'qs' + +const limiter = rateLimit({ + windowMs: 900_000, + max: 100, +}) + +const app: Express = express() + +app.use(cookieParser()) + +app.use(limiter) + +const CLIENT_ID = process.env.VITE_NINETAILED_CLIENT_ID ?? '' +const ENVIRONMENT = process.env.VITE_NINETAILED_ENVIRONMENT ?? '' +const VITE_INSIGHTS_API_BASE_URL = process.env.VITE_INSIGHTS_API_BASE_URL ?? '' +const VITE_EXPERIENCE_API_BASE_URL = process.env.VITE_EXPERIENCE_API_BASE_URL ?? '' + +const render = (sdk: Optimization): string => ` + + + Test SDK page + + + + + + + +` + +const sdk = new Optimization({ + clientId: CLIENT_ID, + environment: ENVIRONMENT, + logLevel: 'debug', + analytics: { baseUrl: VITE_INSIGHTS_API_BASE_URL }, + personalization: { baseUrl: VITE_EXPERIENCE_API_BASE_URL }, +}) + +type QsPrimitive = string | ParsedQs +type QsArray = QsPrimitive[] // Note: mixed arrays are allowed by ParsedQs +type QsValue = QsPrimitive | QsArray | undefined + +function toStringValue(value: QsValue): string | null { + if (value === undefined) return null + if (typeof value === 'string') return value + if (Array.isArray(value)) { + const items = value.map((v) => (typeof v === 'string' ? v : JSON.stringify(v))) + return items.join(',') + } + // value is a ParsedQs object + return JSON.stringify(value) +} + +export function getQueryRecordFromRequest(qs: ParsedQs): Record { + return Object.keys(qs).reduce>((acc, key) => { + const str = toStringValue(qs[key]) + if (str !== null) { + acc[key] = str + } + return acc + }, {}) +} + +function getUniversalEventBuilderArgs(req: Request): UniversalEventBuilderArgs { + const url = new URL(req.protocol + '://' + req.get('host') + req.originalUrl) + return { + locale: req.acceptsLanguages()[0] ?? 'en-US', + userAgent: req.get('User-Agent') ?? 'node-js-server', + page: { + path: req.path, + query: getQueryRecordFromRequest(req.query), + referrer: req.get('Referer') ?? '', + search: url.search, + url: req.url, + }, + } +} + +function getAnonymousIdFromCookies(cookies: unknown): string | undefined { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- req.cookies is of type any + return (cookies as Record)[ANONYMOUS_ID_COOKIE] ?? undefined +} + +function setAnonymousId(res: Response, id: string): void { + res.cookie(ANONYMOUS_ID_COOKIE, id, { + path: '/', + domain: 'localhost', + }) +} + +app.get('/', limiter, async (req, res) => { + const universalEventBuilderArgs = getUniversalEventBuilderArgs(req) + + const anonymousId = getAnonymousIdFromCookies(req.cookies) + + const { profile } = await sdk.personalization.page({ + ...universalEventBuilderArgs, + profile: anonymousId ? { id: anonymousId } : undefined, + }) + + setAnonymousId(res, profile.id) + + res.send(render(sdk)) +}) + +app.get('/user/:userId', limiter, async (req, res) => { + const universalEventBuilderArgs = getUniversalEventBuilderArgs(req) + + const anonymousId = getAnonymousIdFromCookies(req.cookies) + + const { + params: { userId }, + } = req + + if (userId) + await sdk.personalization.identify({ + ...universalEventBuilderArgs, + userId, + profile: anonymousId ? { id: anonymousId } : undefined, + }) + + const { profile } = await sdk.personalization.page({ + ...universalEventBuilderArgs, + profile: anonymousId ? { id: anonymousId } : undefined, + }) + + setAnonymousId(res, profile.id) + + res.send(render(sdk)) +}) + +app.get('/smoke-test', limiter, (_, res) => { + res.send(render(sdk)) +}) + +app.use('/dist', express.static('./public/dist')) + +const port = 3000 + +app.listen(port, () => { + // eslint-disable-next-line no-console -- debug + console.log(`Express is listening at http://localhost:${port}`) +}) + +export default app diff --git a/implementations/node/tsconfig.json b/implementations/node-esr/tsconfig.json similarity index 86% rename from implementations/node/tsconfig.json rename to implementations/node-esr/tsconfig.json index 03c875e1..46535f1b 100644 --- a/implementations/node/tsconfig.json +++ b/implementations/node-esr/tsconfig.json @@ -2,6 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "baseUrl": "./", + "module": "CommonJS", + "moduleResolution": "Node", "outDir": "./dist", "paths": { "@contentful/optimization-api-client": ["../../universal/api-client/src/index.ts"], @@ -10,7 +12,7 @@ "@contentful/optimization-node": ["../../platforms/javascript/node/src/index.ts"] }, "tsBuildInfoFile": "./dist/.tsbuildinfo", - "verbatimModuleSyntax": true + "verbatimModuleSyntax": false }, "include": ["./e2e", "**/*.ts", "./src/**/*", "./src/**/*.json"] } diff --git a/implementations/node-ssr/vitest.config.ts b/implementations/node-esr/vitest.config.ts similarity index 100% rename from implementations/node-ssr/vitest.config.ts rename to implementations/node-esr/vitest.config.ts diff --git a/implementations/node-ssr/README.md b/implementations/node-ssr/README.md index 504e789e..413bea00 100644 --- a/implementations/node-ssr/README.md +++ b/implementations/node-ssr/README.md @@ -1,3 +1,5 @@ -# Contentful Optimization Node.JS Node SSR SDK Reference Implementation +# Contentful Optimization Node SDK Reference Implementation -This is a reference implementation for the [Optimization Node.JS Node SSR SDK](../../platforms/javascript/node/README.md) and is part of the [Contentful Optimization SDK Suite](../../README.md). +This is a reference implementation for the +[Optimization Node SDK](../../platforms/javascript/node/README.md) and is part of the +[Contentful Optimization SDK Suite](../../README.md). diff --git a/implementations/node/e2e/displays-identified-user-variants.spec.ts b/implementations/node-ssr/e2e/displays-identified-user-variants.spec.ts similarity index 100% rename from implementations/node/e2e/displays-identified-user-variants.spec.ts rename to implementations/node-ssr/e2e/displays-identified-user-variants.spec.ts diff --git a/implementations/node/e2e/displays-unidentified-user-variants.spec.ts b/implementations/node-ssr/e2e/displays-unidentified-user-variants.spec.ts similarity index 100% rename from implementations/node/e2e/displays-unidentified-user-variants.spec.ts rename to implementations/node-ssr/e2e/displays-unidentified-user-variants.spec.ts diff --git a/implementations/node-ssr/package.json b/implementations/node-ssr/package.json index cfecda09..f6703421 100644 --- a/implementations/node-ssr/package.json +++ b/implementations/node-ssr/package.json @@ -1,31 +1,29 @@ { "private": true, "name": "@implementation/node-ssr", - "description": "Reference implementation for NodeJS Web projects", + "description": "Reference implementation for NodeJS projects", "license": "MIT", "main": "dist/app.js", "types": "dist/app.d.ts", "scripts": { - "build": "pnpm clean; pnpm build:sdk", - "build:sdk": "pnpm --filter '../../platforms/javascript/(api-schemas|api-client|core|web)' build && mkdir -p ./public/dist && cp -r ../../platforms/javascript/web/dist ./public", - "clean": "rimraf ./dist ./public/dist ./coverage ./playwright-report ./test-results tsconfig.tsbuildinfo", + "clean": "rimraf ./dist ./coverage ./playwright-report ./test-results", "serve": "pnpm serve:mocks && pnpm serve:app", - "serve:app": "pnpm build && pm2 start --name ssr-app \"tsx --env-file=.env ./src/app.ts\"", - "serve:app:stop": "pm2 stop ssr-app && pm2 delete ssr-app", - "serve:mocks": "pm2 start --name ssr-mocks \"pnpm --filter mocks serve\"", - "serve:mocks:stop": "pm2 stop ssr-mocks && pm2 delete ssr-mocks", - "serve:stop": "pnpm serve:app:stop && pnpm serve:mocks:stop", + "serve:app": "pm2 start --name node-app \"tsx --env-file=.env ./src/app.ts\"", + "serve:app:stop": "pm2 stop node-app && pm2 delete node-app", + "serve:mocks": "pm2 start --name node-mocks \"pnpm --filter mocks serve\"", + "serve:mocks:stop": "pm2 stop node-mocks && pm2 delete node-mocks", + "serve:stop": "pnpm serve:app:stop; pnpm serve:mocks:stop", "test:e2e": "pnpm serve && playwright test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", "test:e2e:codegen": "playwright codegen", "test:e2e:report": "playwright show-report", "test:e2e:ui": "playwright test --ui", - "test:unit": "vitest run --coverage", "typecheck": "tsc --noEmit" }, "dependencies": { "@contentful/optimization-node": "workspace:*", - "@contentful/optimization-web": "workspace:*", - "cookie-parser": "^1.4.7", + "@contentful/rich-text-html-renderer": "catalog:", + "@contentful/rich-text-types": "catalog:", + "contentful": "catalog:", "express": "catalog:", "express-rate-limit": "catalog:", "qs": "catalog:", @@ -36,15 +34,10 @@ "@types/express": "catalog:", "@types/node": "catalog:", "@types/qs": "catalog:", - "@types/supertest": "catalog:", - "@vitest/coverage-v8": "catalog:", - "@types/cookie-parser": "1.4.7", "dotenv": "catalog:", "pm2": "catalog:", "rimraf": "catalog:", - "supertest": "catalog:", "tsx": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" + "typescript": "catalog:" } } diff --git a/implementations/node-ssr/playwright.config.mjs b/implementations/node-ssr/playwright.config.mjs index ecbcccc2..2bb70679 100644 --- a/implementations/node-ssr/playwright.config.mjs +++ b/implementations/node-ssr/playwright.config.mjs @@ -31,6 +31,9 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', + + /* Record video only when retrying a test for the first time. */ + video: 'on-first-retry', }, /* Configure projects for major browsers */ diff --git a/implementations/node-ssr/src/app.ts b/implementations/node-ssr/src/app.ts index c44a2574..d89b1344 100644 --- a/implementations/node-ssr/src/app.ts +++ b/implementations/node-ssr/src/app.ts @@ -1,68 +1,101 @@ import Optimization, { - ANONYMOUS_ID_COOKIE, + type OptimizationData, + type OptimizationNodeConfig, + type SelectedPersonalization, type UniversalEventBuilderArgs, } from '@contentful/optimization-node' -import cookieParser from 'cookie-parser' -import express, { type Express, type Request, type Response } from 'express' +import { documentToHtmlString } from '@contentful/rich-text-html-renderer' +import { INLINES, type Document } from '@contentful/rich-text-types' +import type { Entry } from 'contentful' +import * as contentful from 'contentful' +import express, { type Express, type Request } from 'express' import rateLimit from 'express-rate-limit' +import path from 'node:path' import type { ParsedQs } from 'qs' const limiter = rateLimit({ - windowMs: 900_000, - max: 100, + windowMs: 30_000, + max: 1000, }) const app: Express = express() +app.use(limiter) -app.use(cookieParser()) +app.set('view engine', 'pug') // configure Pug as the view engine +app.set('views', path.join(__dirname, '.')) // define the directory for view templates -app.use(limiter) +const optimizationConfig: OptimizationNodeConfig = { + clientId: process.env.VITE_NINETAILED_CLIENT_ID ?? '', + environment: process.env.VITE_NINETAILED_ENVIRONMENT ?? '', + logLevel: 'debug', + analytics: { baseUrl: process.env.VITE_INSIGHTS_API_BASE_URL }, + personalization: { baseUrl: process.env.VITE_EXPERIENCE_API_BASE_URL }, +} + +const sdk = new Optimization(optimizationConfig) + +const ctflConfig: contentful.CreateClientParams = { + accessToken: process.env.VITE_CONTENTFUL_TOKEN ?? '', + environment: process.env.VITE_CONTENTFUL_ENVIRONMENT ?? '', + space: process.env.VITE_CONTENTFUL_SPACE_ID ?? '', + host: process.env.VITE_CONTENTFUL_CDA_HOST ?? '', + basePath: process.env.VITE_CONTENTFUL_BASE_PATH ?? '', + insecure: Boolean(process.env.VITE_CONTENTFUL_CDA_HOST), +} + +const ctfl = contentful.createClient(ctflConfig) + +interface ContentEntrySkeleton { + contentTypeId: 'content' + fields: { + text: contentful.EntryFieldTypes.Text | contentful.EntryFieldTypes.RichText + } +} -const CLIENT_ID = process.env.VITE_NINETAILED_CLIENT_ID ?? '' -const ENVIRONMENT = process.env.VITE_NINETAILED_ENVIRONMENT ?? '' -const VITE_INSIGHTS_API_BASE_URL = process.env.VITE_INSIGHTS_API_BASE_URL ?? '' -const VITE_EXPERIENCE_API_BASE_URL = process.env.VITE_EXPERIENCE_API_BASE_URL ?? '' - -const render = (sdk: Optimization): string => ` - - - Test SDK page - - - - - - - -` +function isRichText(field?: unknown): field is Document { + return ( + typeof field === 'object' && + field !== null && + 'nodeType' in field && + field.nodeType === 'document' + ) +} -const sdk = new Optimization({ - clientId: CLIENT_ID, - environment: ENVIRONMENT, - logLevel: 'debug', - api: { - analytics: { baseUrl: VITE_INSIGHTS_API_BASE_URL }, - personalization: { baseUrl: VITE_EXPERIENCE_API_BASE_URL }, - }, +const entryIds: string[] = [ + '1MwiFl4z7gkwqGYdvCmr8c', // Rich Text field Entry with Merge Tag + '4ib0hsHWoSOnCVdDkizE8d', + 'xFwgG3oNaOcjzWiGe4vXo', + '2Z2WLOx07InSewC3LUB3eX', + '5XHssysWUDECHzKLzoIsg1', + '6zqoWXyiSrf0ja7I2WGtYj', + '7pa5bOx8Z9NmNcr7mISvD', +] + +const entries = new Map>() + +Promise.all( + entryIds.map(async (entryId) => { + const entry = await getContentfulEntry(entryId) + if (!entry) return + entries.set(entryId, entry) + }), +).catch((error: unknown) => { + // eslint-disable-next-line no-console -- debug + console.log(error) }) type QsPrimitive = string | ParsedQs @@ -105,65 +138,80 @@ function getUniversalEventBuilderArgs(req: Request): UniversalEventBuilderArgs { } } -function getAnonymousIdFromCookies(cookies: unknown): string | undefined { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- req.cookies is of type any - return (cookies as Record)[ANONYMOUS_ID_COOKIE] ?? undefined -} - -function setAnonymousId(res: Response, id: string): void { - res.cookie(ANONYMOUS_ID_COOKIE, id, { - path: '/', - domain: 'localhost', - }) -} - app.get('/', limiter, async (req, res) => { const universalEventBuilderArgs = getUniversalEventBuilderArgs(req) - const anonymousId = getAnonymousIdFromCookies(req.cookies) + const userId = isNonEmptyString(req.query.userId) ? req.query.userId.trim() : undefined - const { profile } = await sdk.personalization.page({ - ...universalEventBuilderArgs, - profile: anonymousId ? { id: anonymousId } : undefined, - }) - - setAnonymousId(res, profile.id) - - res.send(render(sdk)) -}) - -app.get('/user/:userId', limiter, async (req, res) => { - const universalEventBuilderArgs = getUniversalEventBuilderArgs(req) + let optimizationResponse: OptimizationData | undefined = undefined - const anonymousId = getAnonymousIdFromCookies(req.cookies) - - const { - params: { userId }, - } = req - - if (userId) - await sdk.personalization.identify({ + if (isNonEmptyString(userId)) { + const pageResponse = await sdk.personalization.page({ + ...universalEventBuilderArgs, + }) + optimizationResponse = await sdk.personalization.identify({ ...universalEventBuilderArgs, userId, - profile: anonymousId ? { id: anonymousId } : undefined, + traits: { identified: true }, + profile: pageResponse.profile, }) + } else { + optimizationResponse = await sdk.personalization.page({ + ...universalEventBuilderArgs, + }) + } + + const { profile, personalizations, changes } = optimizationResponse + + const personalizedEntries = new Map< + string, + { + entry: Entry + personalization?: SelectedPersonalization + } + >() + + entryIds.forEach((entryId) => { + const entry = entries.get(entryId) + + if (!entry) return + + if (isRichText(entry.fields.text)) { + entry.fields.text = documentToHtmlString(entry.fields.text, { + renderNode: { + [INLINES.EMBEDDED_ENTRY]: (node) => { + if (sdk.personalization.mergeTagValueResolver.isMergeTagEntry(node.data.target)) { + return ( + sdk.personalization.mergeTagValueResolver.resolve(node.data.target, profile) ?? '' + ) + } else { + return '' + } + }, + }, + }) + } + + const personalizedEntry = sdk.personalization.personalizedEntryResolver.resolve( + entry, + personalizations, + ) - const { profile } = await sdk.personalization.page({ - ...universalEventBuilderArgs, - profile: anonymousId ? { id: anonymousId } : undefined, + personalizedEntries.set(entryId, personalizedEntry) }) - setAnonymousId(res, profile.id) + const flags = sdk.personalization.flagsResolver.resolve(changes) - res.send(render(sdk)) -}) + const pageData = { + profile, + personalizations, + entries: personalizedEntries, + flags, + } -app.get('/smoke-test', limiter, (_, res) => { - res.send(render(sdk)) + res.render('index', { ...pageData }) }) -app.use('/dist', express.static('./public/dist')) - const port = 3000 app.listen(port, () => { diff --git a/implementations/node/src/index.pug b/implementations/node-ssr/src/index.pug similarity index 100% rename from implementations/node/src/index.pug rename to implementations/node-ssr/src/index.pug diff --git a/implementations/node-ssr/tsconfig.json b/implementations/node-ssr/tsconfig.json index 46535f1b..03c875e1 100644 --- a/implementations/node-ssr/tsconfig.json +++ b/implementations/node-ssr/tsconfig.json @@ -2,8 +2,6 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "baseUrl": "./", - "module": "CommonJS", - "moduleResolution": "Node", "outDir": "./dist", "paths": { "@contentful/optimization-api-client": ["../../universal/api-client/src/index.ts"], @@ -12,7 +10,7 @@ "@contentful/optimization-node": ["../../platforms/javascript/node/src/index.ts"] }, "tsBuildInfoFile": "./dist/.tsbuildinfo", - "verbatimModuleSyntax": false + "verbatimModuleSyntax": true }, "include": ["./e2e", "**/*.ts", "./src/**/*", "./src/**/*.json"] } diff --git a/implementations/node/README.md b/implementations/node/README.md deleted file mode 100644 index 6282c96d..00000000 --- a/implementations/node/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Contentful Optimization Node SDK Reference Implementation - -This is a reference implementation for the [Optimization Node SDK](../../platforms/javascript/node/README.md) and is part of the [Contentful Optimization SDK Suite](../../README.md). diff --git a/implementations/node/package.json b/implementations/node/package.json deleted file mode 100644 index 715ae97a..00000000 --- a/implementations/node/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "private": true, - "name": "@implementation/node", - "description": "Reference implementation for NodeJS projects", - "license": "MIT", - "main": "dist/app.js", - "types": "dist/app.d.ts", - "scripts": { - "clean": "rimraf ./dist ./coverage ./playwright-report ./test-results", - "serve": "pnpm serve:mocks && pnpm serve:app", - "serve:app": "pm2 start --name node-app \"tsx --env-file=.env ./src/app.ts\"", - "serve:app:stop": "pm2 stop node-app && pm2 delete node-app", - "serve:mocks": "pm2 start --name node-mocks \"pnpm --filter mocks serve\"", - "serve:mocks:stop": "pm2 stop node-mocks && pm2 delete node-mocks", - "serve:stop": "pnpm serve:app:stop; pnpm serve:mocks:stop", - "test:e2e": "pnpm serve && playwright test; E2E_RESULT=$?; pnpm serve:stop; exit $E2E_RESULT", - "test:e2e:codegen": "playwright codegen", - "test:e2e:report": "playwright show-report", - "test:e2e:ui": "playwright test --ui", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@contentful/optimization-node": "workspace:*", - "@contentful/rich-text-html-renderer": "catalog:", - "@contentful/rich-text-types": "catalog:", - "contentful": "catalog:", - "express": "catalog:", - "express-rate-limit": "catalog:", - "qs": "catalog:", - "tslib": "catalog:" - }, - "devDependencies": { - "@playwright/test": "catalog:", - "@types/express": "catalog:", - "@types/node": "catalog:", - "@types/qs": "catalog:", - "dotenv": "catalog:", - "pm2": "catalog:", - "rimraf": "catalog:", - "tsx": "catalog:", - "typescript": "catalog:" - } -} diff --git a/implementations/node/src/app.ts b/implementations/node/src/app.ts deleted file mode 100644 index c2806492..00000000 --- a/implementations/node/src/app.ts +++ /dev/null @@ -1,224 +0,0 @@ -import Optimization, { - type OptimizationData, - type OptimizationNodeConfig, - type SelectedPersonalization, - type UniversalEventBuilderArgs, -} from '@contentful/optimization-node' -import { documentToHtmlString } from '@contentful/rich-text-html-renderer' -import { INLINES, type Document } from '@contentful/rich-text-types' -import type { Entry } from 'contentful' -import * as contentful from 'contentful' -import express, { type Express, type Request } from 'express' -import rateLimit from 'express-rate-limit' -import path from 'node:path' -import type { ParsedQs } from 'qs' - -const limiter = rateLimit({ - windowMs: 30_000, - max: 1000, -}) - -const app: Express = express() -app.use(limiter) - -app.set('view engine', 'pug') // configure Pug as the view engine -app.set('views', path.join(__dirname, '.')) // define the directory for view templates - -const optimizationConfig: OptimizationNodeConfig = { - clientId: process.env.VITE_NINETAILED_CLIENT_ID ?? '', - environment: process.env.VITE_NINETAILED_ENVIRONMENT ?? '', - logLevel: 'debug', - api: { - analytics: { baseUrl: process.env.VITE_INSIGHTS_API_BASE_URL }, - personalization: { baseUrl: process.env.VITE_EXPERIENCE_API_BASE_URL }, - }, -} - -const sdk = new Optimization(optimizationConfig) - -const ctflConfig: contentful.CreateClientParams = { - accessToken: process.env.VITE_CONTENTFUL_TOKEN ?? '', - environment: process.env.VITE_CONTENTFUL_ENVIRONMENT ?? '', - space: process.env.VITE_CONTENTFUL_SPACE_ID ?? '', - host: process.env.VITE_CONTENTFUL_CDA_HOST ?? '', - basePath: process.env.VITE_CONTENTFUL_BASE_PATH ?? '', - insecure: Boolean(process.env.VITE_CONTENTFUL_CDA_HOST), -} - -const ctfl = contentful.createClient(ctflConfig) - -interface ContentEntrySkeleton { - contentTypeId: 'content' - fields: { - text: contentful.EntryFieldTypes.Text | contentful.EntryFieldTypes.RichText - } -} - -async function getContentfulEntry( - entryId: string, -): Promise | undefined> { - try { - return await ctfl.getEntry(entryId, { - include: 10, - }) - } catch (_error) {} -} - -function isNonEmptyString(s?: unknown): s is string { - return s !== undefined && typeof s === 'string' && s.trim().length > 0 -} - -function isRichText(field?: unknown): field is Document { - return ( - typeof field === 'object' && - field !== null && - 'nodeType' in field && - field.nodeType === 'document' - ) -} - -const entryIds: string[] = [ - '1MwiFl4z7gkwqGYdvCmr8c', // Rich Text field Entry with Merge Tag - '4ib0hsHWoSOnCVdDkizE8d', - 'xFwgG3oNaOcjzWiGe4vXo', - '2Z2WLOx07InSewC3LUB3eX', - '5XHssysWUDECHzKLzoIsg1', - '6zqoWXyiSrf0ja7I2WGtYj', - '7pa5bOx8Z9NmNcr7mISvD', -] - -const entries = new Map>() - -Promise.all( - entryIds.map(async (entryId) => { - const entry = await getContentfulEntry(entryId) - if (!entry) return - entries.set(entryId, entry) - }), -).catch((error: unknown) => { - // eslint-disable-next-line no-console -- debug - console.log(error) -}) - -type QsPrimitive = string | ParsedQs -type QsArray = QsPrimitive[] // Note: mixed arrays are allowed by ParsedQs -type QsValue = QsPrimitive | QsArray | undefined - -function toStringValue(value: QsValue): string | null { - if (value === undefined) return null - if (typeof value === 'string') return value - if (Array.isArray(value)) { - const items = value.map((v) => (typeof v === 'string' ? v : JSON.stringify(v))) - return items.join(',') - } - // value is a ParsedQs object - return JSON.stringify(value) -} - -export function getQueryRecordFromRequest(qs: ParsedQs): Record { - return Object.keys(qs).reduce>((acc, key) => { - const str = toStringValue(qs[key]) - if (str !== null) { - acc[key] = str - } - return acc - }, {}) -} - -function getUniversalEventBuilderArgs(req: Request): UniversalEventBuilderArgs { - const url = new URL(req.protocol + '://' + req.get('host') + req.originalUrl) - return { - locale: req.acceptsLanguages()[0] ?? 'en-US', - userAgent: req.get('User-Agent') ?? 'node-js-server', - page: { - path: req.path, - query: getQueryRecordFromRequest(req.query), - referrer: req.get('Referer') ?? '', - search: url.search, - url: req.url, - }, - } -} - -app.get('/', limiter, async (req, res) => { - const universalEventBuilderArgs = getUniversalEventBuilderArgs(req) - - const userId = isNonEmptyString(req.query.userId) ? req.query.userId.trim() : undefined - - let optimizationResponse: OptimizationData | undefined = undefined - - if (isNonEmptyString(userId)) { - const pageResponse = await sdk.personalization.page({ - ...universalEventBuilderArgs, - }) - optimizationResponse = await sdk.personalization.identify({ - ...universalEventBuilderArgs, - userId, - traits: { identified: true }, - profile: pageResponse.profile, - }) - } else { - optimizationResponse = await sdk.personalization.page({ - ...universalEventBuilderArgs, - }) - } - - const { profile, personalizations, changes } = optimizationResponse ?? {} - - const personalizedEntries = new Map< - string, - { - entry: Entry - personalization?: SelectedPersonalization - } - >() - - entryIds.forEach((entryId) => { - const entry = entries.get(entryId) - - if (!entry) return - - if (isRichText(entry.fields.text)) { - entry.fields.text = documentToHtmlString(entry.fields.text, { - renderNode: { - [INLINES.EMBEDDED_ENTRY]: (node) => { - if (sdk.personalization.mergeTagValueResolver.isMergeTagEntry(node.data.target)) { - return ( - sdk.personalization.mergeTagValueResolver.resolve(node.data.target, profile) ?? '' - ) - } else { - return '' - } - }, - }, - }) - } - - const personalizedEntry = sdk.personalization.personalizedEntryResolver.resolve( - entry, - personalizations, - ) - - personalizedEntries.set(entryId, personalizedEntry) - }) - - const flags = sdk.personalization.flagsResolver.resolve(changes) - - const pageData = { - profile, - personalizations, - entries: personalizedEntries, - flags, - } - - res.render('index', { ...pageData }) -}) - -const port = 3000 - -app.listen(port, () => { - // eslint-disable-next-line no-console -- debug - console.log(`Express is listening at http://localhost:${port}`) -}) - -export default app diff --git a/implementations/react-native/E2E-TESTING.md b/implementations/react-native/E2E-TESTING.md index 37001f80..8c77259f 100644 --- a/implementations/react-native/E2E-TESTING.md +++ b/implementations/react-native/E2E-TESTING.md @@ -1,6 +1,7 @@ # E2E Testing with Detox -This React Native application is configured with [Detox](https://wix.github.io/Detox/) for end-to-end testing. +This React Native application is configured with [Detox](https://wix.github.io/Detox/) for +end-to-end testing. ## Prerequisites @@ -43,7 +44,8 @@ pnpm run test:e2e:ios:build emulator -list-avds ``` -2. If you need to create one, the default configuration expects an emulator named `Pixel_7_API_34`. You can either: +2. If you need to create one, the default configuration expects an emulator named `Pixel_7_API_34`. + You can either: - Create an emulator with that name - Or update the `.detoxrc.js` file with your emulator name @@ -84,7 +86,8 @@ pnpm --filter @implementation/react-native run e2e:run:android This script handles the complete E2E testing workflow automatically: -1. **Creates `.env` configuration** - Generates a `.env` file with mock server URLs and test credentials +1. **Creates `.env` configuration** - Generates a `.env` file with mock server URLs and test + credentials 2. **Starts mock API server** - Launches the mock server from `lib/mocks` on port 8000 3. **Starts Metro bundler** - Starts the React Native bundler on port 8081 4. **Sets up adb reverse** - Configures port forwarding so the emulator can reach localhost services @@ -200,7 +203,8 @@ The app components have been annotated with `testID` props for Detox to identify ```bash pnpm run start:clean ``` -- Detox issues: Check the [Detox troubleshooting guide](https://wix.github.io/Detox/docs/introduction/troubleshooting) +- Detox issues: Check the + [Detox troubleshooting guide](https://wix.github.io/Detox/docs/introduction/troubleshooting) ## CI/CD Integration @@ -213,7 +217,8 @@ Detox tests can be integrated into your CI/CD pipeline. Make sure to: ### GitHub Actions -The project includes a GitHub Actions workflow (`.github/workflows/main-pipeline.yaml`) that runs Android e2e tests on pull requests. +The project includes a GitHub Actions workflow (`.github/workflows/main-pipeline.yaml`) that runs +Android e2e tests on pull requests. #### Testing Headless Locally (Before CI) @@ -240,7 +245,8 @@ To test the headless emulator setup locally before pushing to CI: #### CI Workflow Example -The CI workflow uses `reactivecircus/android-emulator-runner` action which handles emulator lifecycle automatically. The action creates its own AVD, so no manual AVD creation is needed: +The CI workflow uses `reactivecircus/android-emulator-runner` action which handles emulator +lifecycle automatically. The action creates its own AVD, so no manual AVD creation is needed: ```yaml - name: Start Android Emulator diff --git a/implementations/react-native/README.md b/implementations/react-native/README.md new file mode 100644 index 00000000..e69de29b diff --git a/implementations/react-native/utils/sdkHelpers.ts b/implementations/react-native/utils/sdkHelpers.ts index 90fad01d..f8bd8754 100644 --- a/implementations/react-native/utils/sdkHelpers.ts +++ b/implementations/react-native/utils/sdkHelpers.ts @@ -28,10 +28,8 @@ export async function initializeSDK( const sdkInstance = await Optimization.create({ clientId: ENV_CONFIG.optimization.clientId, environment: ENV_CONFIG.optimization.environment, - api: { - personalization: { baseUrl: ENV_CONFIG.api.experienceBaseUrl }, - analytics: { baseUrl: ENV_CONFIG.api.insightsBaseUrl }, - }, + personalization: { baseUrl: ENV_CONFIG.api.experienceBaseUrl }, + analytics: { baseUrl: ENV_CONFIG.api.insightsBaseUrl }, logLevel: 'debug', }) diff --git a/implementations/web-vanilla/README.md b/implementations/web-vanilla/README.md index e758a399..dcf61e58 100644 --- a/implementations/web-vanilla/README.md +++ b/implementations/web-vanilla/README.md @@ -1,3 +1,5 @@ # Contentful Optimization Web SDK Reference Implementation -This is a reference implementation for the [Optimization Web SDK](../../platforms/javascript/web/README.md) and is part of the [Contentful Optimization SDK Suite](../../README.md). +This is a reference implementation for the +[Optimization Web SDK](../../platforms/javascript/web/README.md) and is part of the +[Contentful Optimization SDK Suite](../../README.md). diff --git a/implementations/web-vanilla/public/index.html b/implementations/web-vanilla/public/index.html index 4645c1b7..bb7f2b18 100644 --- a/implementations/web-vanilla/public/index.html +++ b/implementations/web-vanilla/public/index.html @@ -182,16 +182,13 @@

Event Stream

clientId: '', environment: '', logLevel: 'debug', - autoObserveEntryElements: true, autoTrackEntryViews: true, app: { name: document.title, version: '0.0.0', }, - api: { - analytics: { baseUrl: '' }, - personalization: { baseUrl: '' }, - }, + analytics: { baseUrl: '' }, + personalization: { baseUrl: '' }, }) // Emit page event diff --git a/lib/logger/package.json b/lib/logger/package.json index db3520ee..89f0ec9e 100644 --- a/lib/logger/package.json +++ b/lib/logger/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "logger", - "version": "1.0.0", + "version": "0.0.0", "license": "MIT", "main": "./src/index.ts", "scripts": { diff --git a/lib/logger/src/ConsoleLogSink.test.ts b/lib/logger/src/ConsoleLogSink.test.ts index c958df12..3f157b95 100644 --- a/lib/logger/src/ConsoleLogSink.test.ts +++ b/lib/logger/src/ConsoleLogSink.test.ts @@ -26,9 +26,9 @@ describe('ConsoleLogSink', () => { Object.assign(console, originalConsole) }) - it('defaults to fatal verbosity', () => { + it('defaults to `error` verbosity', () => { const sink = new ConsoleLogSink() - expect(sink.verbosity).toBe('fatal') + expect(sink.verbosity).toBe('error') }) it('uses the provided verbosity', () => { diff --git a/lib/logger/src/ConsoleLogSink.ts b/lib/logger/src/ConsoleLogSink.ts index f2dc8827..47f97d66 100644 --- a/lib/logger/src/ConsoleLogSink.ts +++ b/lib/logger/src/ConsoleLogSink.ts @@ -34,7 +34,7 @@ export class ConsoleLogSink extends LogSink { constructor(verbosity?: LogLevels) { super() - this.verbosity = verbosity ?? 'fatal' + this.verbosity = verbosity ?? 'error' } ingest(event: LogEvent): void { diff --git a/package.json b/package.json index 3bf5fd81..38c4725a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build:implementations": "pnpm --filter @implementation/* build", "clean": "pnpm -r --parallel clean", "docs:generate": "typedoc", + "docs:watch": "typedoc --watch", "format:check": "prettier . --check", "format:fix": "prettier . --check --write", "lint:check": "eslint .", @@ -28,7 +29,7 @@ "engines": { "node": ">=16.20.0" }, - "packageManager": "pnpm@10.22.0+sha512.bf049efe995b28f527fd2b41ae0474ce29186f7edcb3bf545087bd61fbbebb2bf75362d1307fda09c2d288e1e499787ac12d4fcb617a974718a6051f2eee741c", + "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501", "devDependencies": { "@eslint/js": "^9.32.0", "commitizen": "^4.3.1", diff --git a/platforms/javascript/node/README.md b/platforms/javascript/node/README.md index d192c171..f10b3135 100644 --- a/platforms/javascript/node/README.md +++ b/platforms/javascript/node/README.md @@ -1,5 +1,13 @@ # Optimization Node SDK -This SDK implements functionality specific to Node environments. It is based on the [Optimization Core Library](../core/README.md). +This SDK implements functionality specific to Node environments. It is based on the +[Optimization Core Library](/universal/core/README.md). -This SDK is part of the [Contentful Optimization SDK Suite](../../../README.md). +This SDK is part of the [Contentful Optimization SDK Suite](/README.md). + +## Reference Implementations + +- [Node SSR](/implementations/node-ssr/README.md): Example application that uses the Node SDK to + render a personalized Web page +- [Node ESR](/implementations/node-esr/README.md): Example application demonstrating simple profile + synchronization between the Node and Web SDKs via cookie diff --git a/platforms/javascript/node/package.json b/platforms/javascript/node/package.json index 9e7fd161..c11f7a48 100644 --- a/platforms/javascript/node/package.json +++ b/platforms/javascript/node/package.json @@ -1,6 +1,6 @@ { "name": "@contentful/optimization-node", - "version": "1.0.0", + "version": "0.0.0", "license": "MIT", "type": "commonjs", "main": "./dist/index.cjs", diff --git a/platforms/javascript/node/server.ts b/platforms/javascript/node/server.ts index 3806a27b..7e2a7508 100644 --- a/platforms/javascript/node/server.ts +++ b/platforms/javascript/node/server.ts @@ -27,10 +27,8 @@ const sdk = new Optimization({ clientId: process.env.VITE_NINETAILED_CLIENT_ID ?? '', environment: process.env.VITE_NINETAILED_ENVIRONMENT ?? '', logLevel: 'debug', - api: { - analytics: { baseUrl: process.env.VITE_INSIGHTS_API_BASE_URL }, - personalization: { baseUrl: process.env.VITE_EXPERIENCE_API_BASE_URL }, - }, + analytics: { baseUrl: process.env.VITE_INSIGHTS_API_BASE_URL }, + personalization: { baseUrl: process.env.VITE_EXPERIENCE_API_BASE_URL }, }) const ctfl = contentful.createClient({ @@ -141,7 +139,7 @@ app.get('/', limiter, async (req, res) => { apiResponse = await sdk.personalization.page({ profile: requestProfile }) } - const { profile, personalizations, changes } = apiResponse ?? {} + const { profile, personalizations, changes } = apiResponse const entryIds: string[] = [ '1MwiFl4z7gkwqGYdvCmr8c', // Rich Text field Entry with Merge Tag diff --git a/platforms/javascript/react-native/README.md b/platforms/javascript/react-native/README.md index 24257de9..fc265a09 100644 --- a/platforms/javascript/react-native/README.md +++ b/platforms/javascript/react-native/README.md @@ -8,6 +8,12 @@ Contentful Optimization SDK for React Native applications. npm install @contentful/optimization-react-native @react-native-async-storage/async-storage ``` +## Reference Implementation + +- [React Native](/implementations/react-native/README.md): Example application that displays + personalized content, with builds targeted for both Android and iOS (TODO: update link when README + is added) + ## Quick Start ```typescript @@ -63,13 +69,16 @@ function App() { ## Component Tracking -**Important:** When we refer to "component tracking," we're talking about tracking **Contentful entry components** (content entries in your CMS), NOT React Native UI components. The term "component" comes from Contentful's terminology for personalized content entries. +**Important:** When we refer to "component tracking," we're talking about tracking **Contentful +entry components** (content entries in your CMS), NOT React Native UI components. The term +"component" comes from Contentful's terminology for personalized content entries. The SDK provides two semantic components for tracking different types of Contentful entries: ### `` - For Personalized Entries -Use this component to track Contentful entries that can be personalized (have `nt_experiences` field). It automatically: +Use this component to track Contentful entries that can be personalized (have `nt_experiences` +field). It automatically: - Resolves the correct variant based on user profile and active personalizations - Provides the resolved entry via render prop @@ -95,7 +104,8 @@ The tracking components work in two modes: #### Inside ScrollView (Recommended for Scrollable Content) -When used inside a ``, tracking uses the actual scroll position and viewport dimensions: +When used inside a ``, tracking uses the actual scroll position and viewport +dimensions: ```tsx @@ -129,7 +139,8 @@ When used without ``, tracking uses screen dimensions instead: ``` -**Note:** In this mode, `scrollY` is always `0` and viewport height equals the screen height. This is ideal for: +**Note:** In this mode, `scrollY` is always `0` and viewport height equals the screen height. This +is ideal for: - Full-screen components - Non-scrollable layouts @@ -137,17 +148,20 @@ When used without ``, tracking uses screen dimensions instead: ## Features -This SDK provides all the functionality from `@contentful/optimization-core` plus React Native-specific features: +This SDK provides all the functionality from `@contentful/optimization-core` plus React +Native-specific features: ### React Native Specific -- **Personalization**: Component for tracking personalized Contentful entries with variant resolution +- **Personalization**: Component for tracking personalized Contentful entries with variant + resolution - **Analytics**: Component for tracking non-personalized Contentful entries - **OptimizationProvider**: React context provider for accessing the Optimization instance - **ScrollProvider**: Wrapper around ScrollView that enables viewport tracking - **useOptimization**: Hook to access the Optimization instance in components - **useViewportTracking**: Hook for custom viewport tracking logic -- **AsyncStorage Integration**: Automatic persistence of state using `@react-native-async-storage/async-storage` +- **AsyncStorage Integration**: Automatic persistence of state using + `@react-native-async-storage/async-storage` - **React Native Defaults**: Pre-configured event builders for mobile context ### Core Functionality (Re-exported) @@ -164,7 +178,8 @@ All core SDK features are available directly from this package: #### Tracking Personalized Entries -The `` component handles variant resolution and tracking for personalized Contentful entries: +The `` component handles variant resolution and tracking for personalized +Contentful entries: ```typescript import { createClient } from 'contentful' @@ -366,8 +381,12 @@ The SDK automatically configures: The SDK includes automatic polyfills for React Native to support modern JavaScript features: -- **Iterator Helpers (ES2025)**: Polyfilled using `es-iterator-helpers` to support methods like `.toArray()`, `.filter()`, `.map()` on iterators -- **`crypto.randomUUID()`**: Polyfilled using `react-native-uuid` to ensure the universal EventBuilder works seamlessly -- **`crypto.getRandomValues()`**: Polyfilled using `react-native-get-random-values` for secure random number generation +- **Iterator Helpers (ES2025)**: Polyfilled using `es-iterator-helpers` to support methods like + `.toArray()`, `.filter()`, `.map()` on iterators +- **`crypto.randomUUID()`**: Polyfilled using `react-native-uuid` to ensure the universal + EventBuilder works seamlessly +- **`crypto.getRandomValues()`**: Polyfilled using `react-native-get-random-values` for secure + random number generation -These polyfills are imported automatically when you use the SDK - no additional setup required by your app. +These polyfills are imported automatically when you use the SDK - no additional setup required by +your app. diff --git a/platforms/javascript/react-native/dev/utils/sdkHelpers.ts b/platforms/javascript/react-native/dev/utils/sdkHelpers.ts index fb5a7f62..788e7e73 100644 --- a/platforms/javascript/react-native/dev/utils/sdkHelpers.ts +++ b/platforms/javascript/react-native/dev/utils/sdkHelpers.ts @@ -24,10 +24,8 @@ export async function initializeSDK( const sdkInstance = await Optimization.create({ clientId, environment, - api: { - personalization: { baseUrl: experienceBaseUrl }, - analytics: { baseUrl: insightsBaseUrl }, - }, + personalization: { baseUrl: experienceBaseUrl }, + analytics: { baseUrl: insightsBaseUrl }, logLevel: 'debug', }) diff --git a/platforms/javascript/react-native/package.json b/platforms/javascript/react-native/package.json index 5d968fff..9d6c3f7f 100644 --- a/platforms/javascript/react-native/package.json +++ b/platforms/javascript/react-native/package.json @@ -1,6 +1,6 @@ { "name": "@contentful/optimization-react-native", - "version": "1.0.0", + "version": "0.0.0", "license": "MIT", "type": "commonjs", "main": "./dist/index.cjs", diff --git a/platforms/javascript/web/README.md b/platforms/javascript/web/README.md index 515fe338..5d9c4cda 100644 --- a/platforms/javascript/web/README.md +++ b/platforms/javascript/web/README.md @@ -1,5 +1,11 @@ # Optimization Web SDK -This SDK implements functionality specific to the Web environment. It is based on the [Optimization Core Library](../core/README.md). +This SDK implements functionality specific to the Web environment. It is based on the +[Optimization Core Library](/universal/core/README.md). -This SDK is part of the [Contentful Optimization SDK Suite](../../../README.md). +This SDK is part of the [Contentful Optimization SDK Suite](/README.md). + +## Reference Implementation + +- [Web Vanilla](/implementations/web-vanilla/README.md): Example static Web page that renders and + emits analytics events for personalized content using a vanilla JS drop-in build of the Web SDK diff --git a/platforms/javascript/web/index.html b/platforms/javascript/web/index.html index bc9a58c1..26daf9d7 100644 --- a/platforms/javascript/web/index.html +++ b/platforms/javascript/web/index.html @@ -251,16 +251,13 @@

Entries

clientId: '%VITE_NINETAILED_CLIENT_ID%', environment: '%VITE_NINETAILED_ENVIRONMENT%', logLevel: 'debug', - autoObserveEntryElements: true, autoTrackEntryViews: true, app: { name: document.title, version: '0.0.0', }, - api: { - analytics: { baseUrl: '%VITE_INSIGHTS_API_BASE_URL%' }, - personalization: { baseUrl: '%VITE_EXPERIENCE_API_BASE_URL%' }, - }, + analytics: { baseUrl: '%VITE_INSIGHTS_API_BASE_URL%' }, + personalization: { baseUrl: '%VITE_EXPERIENCE_API_BASE_URL%' }, }) // Emit page event diff --git a/platforms/javascript/web/package.json b/platforms/javascript/web/package.json index b1a7a4d3..65ea1797 100644 --- a/platforms/javascript/web/package.json +++ b/platforms/javascript/web/package.json @@ -1,6 +1,6 @@ { "name": "@contentful/optimization-web", - "version": "1.0.0", + "version": "0.0.0", "license": "MIT", "type": "module", "exports": { diff --git a/platforms/javascript/web/src/AutoEntryViewTracking.ts b/platforms/javascript/web/src/AutoEntryViewTracking.ts index 70400e17..dbdb4e1a 100644 --- a/platforms/javascript/web/src/AutoEntryViewTracking.ts +++ b/platforms/javascript/web/src/AutoEntryViewTracking.ts @@ -1,8 +1,4 @@ -import { - type AnalyticsStateful, - logger, - type PersonalizationStateful, -} from '@contentful/optimization-core' +import { type CoreStateful, logger } from '@contentful/optimization-core' import type { ElementExistenceObserverOptions, ElementViewCallbackInfo, @@ -64,13 +60,7 @@ function parseVariantIndex(variantIndex: string | undefined): number | undefined } export const createAutoTrackingEntryViewCallback = - ({ - personalization, - analytics, - }: { - personalization: PersonalizationStateful - analytics: AnalyticsStateful - }) => + (core: CoreStateful) => async (element: Element, info: ElementViewCallbackInfo): Promise => { if (!isEntryData(info.data) && !isEntryElement(element)) return @@ -103,25 +93,16 @@ export const createAutoTrackingEntryViewCallback = if (!entryId) { logger.warn( - '[Element View Observer Callback] No entry data found; please add data attributes or observe with data info', + '[Optimization Web SDK] No entry data found in entry view observer callback; please add data attributes or observe with data info', ) return } - if (sticky) - await personalization.trackComponentView( - { - componentId: entryId, - experienceId: personalizationId, - variantIndex, - }, - duplicationScope, - ) - - await analytics.trackComponentView( + await core.trackComponentView( { componentId: entryId, experienceId: personalizationId, + sticky, variantIndex, }, duplicationScope, @@ -146,7 +127,7 @@ export const createAutoTrackingEntryExistenceCallback = ( if (!ctflElement || !entryViewObserver.getStats(ctflElement)) return - logger.info('[Optimization Web SDK] Auto-removing element (remove):', ctflElement) + logger.info('[Optimization Web SDK] Auto-unobserving element (remove):', ctflElement) entryViewObserver.unobserve(ctflElement) }) }, diff --git a/platforms/javascript/web/src/Optimization.ts b/platforms/javascript/web/src/Optimization.ts index 4587210d..7a4b85a8 100644 --- a/platforms/javascript/web/src/Optimization.ts +++ b/platforms/javascript/web/src/Optimization.ts @@ -14,13 +14,14 @@ import { createAutoTrackingEntryViewCallback, isEntryElement, } from './AutoEntryViewTracking' -import { beaconHandler } from './beacon' -import { getAnonymousId, getLocale, getPageProperties, getUserAgent } from './builders' +import { getLocale, getPageProperties, getUserAgent } from './builders' import { ANONYMOUS_ID_COOKIE } from './global-constants' +import { beaconHandler, createVisibilityChangeListener } from './handlers' import { ElementExistenceObserver, type ElementViewElementOptions, ElementViewObserver, + type ElementViewObserverOptions, } from './observers' import { LocalStore } from './storage' @@ -34,7 +35,6 @@ declare global { export interface OptimizationWebConfig extends CoreStatefulConfig { app?: App autoTrackEntryViews?: boolean - autoObserveEntryElements?: boolean } function mergeConfig({ @@ -55,9 +55,7 @@ function mergeConfig({ return merge( { - api: { - analytics: { beaconHandler }, - }, + analytics: { beaconHandler }, defaults: { consent, analytics: { @@ -73,11 +71,11 @@ function mergeConfig({ app, channel: 'web', library: { name: 'Optimization Web API', version: '0.0.0' }, - getAnonymousId, getLocale, getPageProperties, getUserAgent, }, + getAnonymousId: () => LocalStore.anonymousId, logLevel: LocalStore.debug ? 'debug' : logLevel, }, config, @@ -88,20 +86,23 @@ class Optimization extends CoreStateful { private elementViewObserver?: ElementViewObserver = undefined private elementExistenceObserver?: ElementExistenceObserver = undefined - autoObserveEntryElements = false - autoTrackEntryViews = false + private autoTrackEntryViews = false constructor(config: OptimizationWebConfig) { - if (window.optimization) throw new Error('Optimization is already initialized') + if (typeof window !== 'undefined' && window.optimization) + throw new Error('Optimization is already initialized') - const { autoObserveEntryElements, autoTrackEntryViews, ...restConfig } = config + const { autoTrackEntryViews, ...restConfig } = config const mergedConfig: CoreConfig = mergeConfig(restConfig) super(mergedConfig) - this.autoObserveEntryElements = autoObserveEntryElements ?? false - this.autoTrackEntryViews = autoTrackEntryViews ?? false + this.autoTrackEntryViews = true + + createVisibilityChangeListener(async () => { + await this.analytics.flush() + }) effect(() => { const { @@ -134,7 +135,6 @@ class Optimization extends CoreStateful { LocalStore.anonymousId = value?.id ?? cookieValue - // TODO: Allow cookie attributes to be set if (value && value.id !== cookieValue) Cookies.set(ANONYMOUS_ID_COOKIE, value.id) }) @@ -145,25 +145,19 @@ class Optimization extends CoreStateful { LocalStore.personalizations = value }) + + if (typeof window !== 'undefined') window.optimization ??= this } - startAutoTrackingEntryViews(options?: ElementViewElementOptions): void { - this.elementViewObserver = new ElementViewObserver( - createAutoTrackingEntryViewCallback({ - personalization: this.personalization, - analytics: this.analytics, - }), - ) + startAutoTrackingEntryViews(options?: ElementViewObserverOptions): void { + this.autoTrackEntryViews = true + + this.elementViewObserver = new ElementViewObserver(createAutoTrackingEntryViewCallback(this)) this.elementExistenceObserver = new ElementExistenceObserver( - createAutoTrackingEntryExistenceCallback( - this.elementViewObserver, - this.autoObserveEntryElements, - ), + createAutoTrackingEntryExistenceCallback(this.elementViewObserver, true), ) - if (!this.autoObserveEntryElements) return - // Fully-automated observation for elements with ctfl data attributes const entries = document.querySelectorAll('[data-ctfl-entry-id]') @@ -201,7 +195,6 @@ class Optimization extends CoreStateful { } } -// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- protect against non-Web -if (window) window.Optimization ??= Optimization +if (typeof window !== 'undefined') window.Optimization ??= Optimization export default Optimization diff --git a/platforms/javascript/web/src/beacon/index.ts b/platforms/javascript/web/src/beacon/index.ts deleted file mode 100644 index d409f890..00000000 --- a/platforms/javascript/web/src/beacon/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './beaconHandler' diff --git a/platforms/javascript/web/src/builders/EventBuilder.ts b/platforms/javascript/web/src/builders/EventBuilder.ts index 073dbe9f..92a78dff 100644 --- a/platforms/javascript/web/src/builders/EventBuilder.ts +++ b/platforms/javascript/web/src/builders/EventBuilder.ts @@ -1,5 +1,4 @@ import { logger, type Dictionary, type Page } from '@contentful/optimization-core' -import LocalStore from '../storage/LocalStore' function buildQuery(url: string | URL): Dictionary { return new URL(url).searchParams.entries().reduce((entries: Dictionary, [k, v]) => { @@ -8,10 +7,6 @@ function buildQuery(url: string | URL): Dictionary { }, {}) } -export function getAnonymousId(): string | undefined { - return LocalStore.anonymousId -} - export function getLocale(): string { const { languages, language } = navigator diff --git a/platforms/javascript/web/src/global-constants.ts b/platforms/javascript/web/src/global-constants.ts index 3a6846d0..dc8a41d4 100644 --- a/platforms/javascript/web/src/global-constants.ts +++ b/platforms/javascript/web/src/global-constants.ts @@ -1 +1,6 @@ export { ANONYMOUS_ID_COOKIE } from '@contentful/optimization-core' + +export const CAN_ADD_LISTENERS = + typeof window !== 'undefined' && + typeof document !== 'undefined' && + typeof document.addEventListener === 'function' diff --git a/platforms/javascript/web/src/beacon/beaconHandler.ts b/platforms/javascript/web/src/handlers/beaconHandler.ts similarity index 52% rename from platforms/javascript/web/src/beacon/beaconHandler.ts rename to platforms/javascript/web/src/handlers/beaconHandler.ts index 9709324d..cec47496 100644 --- a/platforms/javascript/web/src/beacon/beaconHandler.ts +++ b/platforms/javascript/web/src/handlers/beaconHandler.ts @@ -1,7 +1,7 @@ import type { BatchInsightsEventArray } from '@contentful/optimization-core' -export function beaconHandler(url: string | URL, data: BatchInsightsEventArray): boolean { - const blobData = new Blob([JSON.stringify(data)], { +export function beaconHandler(url: string | URL, events: BatchInsightsEventArray): boolean { + const blobData = new Blob([JSON.stringify(events)], { type: 'text/plain', }) diff --git a/platforms/javascript/web/src/handlers/createVisibilityChangeListener.ts b/platforms/javascript/web/src/handlers/createVisibilityChangeListener.ts new file mode 100644 index 00000000..48caae5e --- /dev/null +++ b/platforms/javascript/web/src/handlers/createVisibilityChangeListener.ts @@ -0,0 +1,63 @@ +import { logger } from '@contentful/optimization-core' +import { CAN_ADD_LISTENERS } from '../global-constants' + +type HideEvent = Event | PageTransitionEvent +type Callback = (event: HideEvent) => Promise | void + +export function createVisibilityChangeListener(callback: Callback): () => void { + if (!CAN_ADD_LISTENERS) { + return () => { + void 0 + } + } + + let handled = false + + const handleHide = (event: HideEvent): void => { + if (handled) return + handled = true + + void (async () => { + try { + await callback(event) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error( + '[Optimization Web SDK] Error encountered while handling page visibility change:', + message, + ) + } + })() + } + + const resetHandled = (): void => { + handled = false + } + + const onVisibilityChange = (event: Event): void => { + if (document.visibilityState === 'hidden') { + handleHide(event) + } else { + resetHandled() + } + } + + const onPageHide = (event: PageTransitionEvent): void => { + handleHide(event) + } + + const onPageShow = (): void => { + resetHandled() + } + + document.addEventListener('visibilitychange', onVisibilityChange) + window.addEventListener('pagehide', onPageHide) + window.addEventListener('pageshow', onPageShow) + + // Cleanup function + return () => { + document.removeEventListener('visibilitychange', onVisibilityChange) + window.removeEventListener('pagehide', onPageHide) + window.removeEventListener('pageshow', onPageShow) + } +} diff --git a/platforms/javascript/web/src/handlers/index.ts b/platforms/javascript/web/src/handlers/index.ts new file mode 100644 index 00000000..797fa19b --- /dev/null +++ b/platforms/javascript/web/src/handlers/index.ts @@ -0,0 +1,2 @@ +export * from './beaconHandler' +export * from './createVisibilityChangeListener' diff --git a/platforms/javascript/web/src/index.ts b/platforms/javascript/web/src/index.ts index bde05760..e7efb56a 100644 --- a/platforms/javascript/web/src/index.ts +++ b/platforms/javascript/web/src/index.ts @@ -2,7 +2,7 @@ export { default as Optimization } from './Optimization' export * from '@contentful/optimization-core' -export * from './beacon/beaconHandler' export * from './builders/EventBuilder' export * from './global-constants' +export * from './handlers/beaconHandler' export * from './storage/LocalStore' diff --git a/platforms/javascript/web/src/observers/ElementExistenceObserver.ts b/platforms/javascript/web/src/observers/ElementExistenceObserver.ts index 50365c51..03de038f 100644 --- a/platforms/javascript/web/src/observers/ElementExistenceObserver.ts +++ b/platforms/javascript/web/src/observers/ElementExistenceObserver.ts @@ -7,15 +7,17 @@ * - Two optional per-kind callbacks + one aggregate callback */ +import { CAN_ADD_LISTENERS } from '../global-constants' + export const DEFAULT_IDLE_TIMEOUT_MS = 100 export const DEFAULT_MAX_CHUNK = 250 export const MIN_IDLE_TIMEOUT_MS = 16 // ~1 frame export const MIN_MAX_CHUNK = 1 -export const CAN_USE_DOM = typeof window !== 'undefined' && typeof document !== 'undefined' -export const HAS_MUTATION_OBSERVER = CAN_USE_DOM && typeof MutationObserver !== 'undefined' -export const HAS_IDLE_CALLBACK = CAN_USE_DOM && typeof window.requestIdleCallback === 'function' -export const HAS_CANCEL_IDLE = CAN_USE_DOM && typeof window.cancelIdleCallback === 'function' +export const HAS_MUTATION_OBSERVER = CAN_ADD_LISTENERS && typeof MutationObserver !== 'undefined' +export const HAS_IDLE_CALLBACK = + CAN_ADD_LISTENERS && typeof window.requestIdleCallback === 'function' +export const HAS_CANCEL_IDLE = CAN_ADD_LISTENERS && typeof window.cancelIdleCallback === 'function' export interface MutationChange { readonly added: ReadonlySet @@ -65,7 +67,7 @@ class ElementExistenceObserver { onError, } = options - this.root = ElementExistenceObserver.isNode(root) ? root : CAN_USE_DOM ? document : null + this.root = ElementExistenceObserver.isNode(root) ? root : CAN_ADD_LISTENERS ? document : null this.idleTimeoutMs = ElementExistenceObserver.sanitizeInt( idleTimeoutMs, @@ -158,7 +160,7 @@ class ElementExistenceObserver { } private static isNode(value: unknown): value is Node { - return CAN_USE_DOM && typeof Node !== 'undefined' && value instanceof Node + return CAN_ADD_LISTENERS && typeof Node !== 'undefined' && value instanceof Node } private static sanitizeInt(value: unknown, fallback: number, min: number): number { @@ -226,7 +228,7 @@ class ElementExistenceObserver { private static cancelIdle(handle: number): void { if (HAS_CANCEL_IDLE) { window.cancelIdleCallback(handle) - } else if (CAN_USE_DOM) { + } else if (CAN_ADD_LISTENERS) { window.clearTimeout(handle) } } @@ -282,7 +284,7 @@ class ElementExistenceObserver { if (index < items.length) { if (HAS_IDLE_CALLBACK) { window.requestIdleCallback(run, { timeout: this.idleTimeoutMs }) - } else if (CAN_USE_DOM) { + } else if (CAN_ADD_LISTENERS) { window.setTimeout(run, this.idleTimeoutMs) } } diff --git a/platforms/javascript/web/src/observers/ElementView.test.ts b/platforms/javascript/web/src/observers/ElementView.test.ts index bef6dd86..d8f1fda2 100644 --- a/platforms/javascript/web/src/observers/ElementView.test.ts +++ b/platforms/javascript/web/src/observers/ElementView.test.ts @@ -4,7 +4,6 @@ import { clearFireTimer, DEFAULTS, derefElement, - HAS_DOC, isPageVisible, NOW, Num, @@ -58,12 +57,6 @@ afterEach(() => { }) describe('Environment flags', () => { - it('HAS_DOC reflects presence of document.addEventListener', () => { - expect(HAS_DOC).toBe( - typeof document !== 'undefined' && typeof document.addEventListener === 'function', - ) - }) - it('isPageVisible follows document.visibilityState (visible)', () => { setVisibilityState('visible') expect(isPageVisible()).toBe(true) diff --git a/platforms/javascript/web/src/observers/ElementView.ts b/platforms/javascript/web/src/observers/ElementView.ts index b798b61e..39a835e4 100644 --- a/platforms/javascript/web/src/observers/ElementView.ts +++ b/platforms/javascript/web/src/observers/ElementView.ts @@ -2,12 +2,11 @@ * Shared types, tunables, environment helpers, and state utilities for ElementViewObserver. */ +import { CAN_ADD_LISTENERS } from '../global-constants' + export type Timer = ReturnType export type Interval = ReturnType -export const HAS_DOC = - typeof document !== 'undefined' && typeof document.addEventListener === 'function' - /** High-resolution time when available. */ export const NOW = (): number => typeof performance !== 'undefined' && typeof performance.now === 'function' @@ -16,7 +15,7 @@ export const NOW = (): number => /** Page visibility helper (true when no document is available). */ export const isPageVisible = (): boolean => - !HAS_DOC ? true : document.visibilityState === 'visible' + !CAN_ADD_LISTENERS ? true : document.visibilityState === 'visible' /** ---- Tunables ---- */ export const DEFAULTS = { diff --git a/platforms/javascript/web/src/observers/ElementViewObserver.ts b/platforms/javascript/web/src/observers/ElementViewObserver.ts index 60abdefe..7b6c28dd 100644 --- a/platforms/javascript/web/src/observers/ElementViewObserver.ts +++ b/platforms/javascript/web/src/observers/ElementViewObserver.ts @@ -3,7 +3,6 @@ * - Tracks cumulative visible time per element * - Fires a sync/async callback exactly once per observed element * - Retries on failure with exponential backoff (retry scope is per "visibility cycle") - * - Uses WeakRef to avoid strong element references (with a safe fallback when unavailable) * - Pauses when the page/tab is hidden; resumes cleanly * - Coalesces retries to avoid duplicate concurrent attempts * - Supports per-element overrides and optional data on observe() @@ -12,6 +11,7 @@ * Assumes DOM environment (browser). */ +import { CAN_ADD_LISTENERS } from '../global-constants' import { DEFAULTS, type EffectiveObserverOptions, @@ -19,7 +19,6 @@ import { type ElementViewCallback, type ElementViewElementOptions, type ElementViewObserverOptions, - HAS_DOC, type Interval, NOW, Num, @@ -56,7 +55,7 @@ class ElementViewObserver { threshold: this.opts.minVisibleRatio === 0 ? [0] : [0, this.opts.minVisibleRatio], }, ) - if (HAS_DOC) { + if (CAN_ADD_LISTENERS) { this.boundVisibilityHandler = () => { this.onPageVisibilityChange() } @@ -100,7 +99,7 @@ class ElementViewObserver { s.strongRef = null } this.activeStates.clear() - if (HAS_DOC && this.boundVisibilityHandler) { + if (CAN_ADD_LISTENERS && this.boundVisibilityHandler) { document.removeEventListener('visibilitychange', this.boundVisibilityHandler) this.boundVisibilityHandler = null } @@ -407,7 +406,7 @@ class ElementViewObserver { } private sweepOrphans(): void { - if (!HAS_DOC) return + if (!CAN_ADD_LISTENERS) return for (const state of Array.from(this.activeStates)) { const element = derefElement(state) if (!element) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2f51bcb..80e22a5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,7 +71,7 @@ catalogs: version: 4.1.5 overrides: - '@playwright/test': ^1.55.1 + '@playwright/test': ^1.57.0 '@types/node': ^24.0.13 typescript: ^5.8.3 vitest: ^3.2.4 @@ -129,20 +129,17 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/node@24.2.0)(happy-dom@20.0.2)(jiti@2.5.1)(msw@2.10.5(@types/node@24.2.0)(typescript@5.9.2))(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1) - implementations/node: + implementations/node-esr: dependencies: '@contentful/optimization-node': specifier: workspace:* version: link:../../platforms/javascript/node/dist - '@contentful/rich-text-html-renderer': - specifier: 'catalog:' - version: 17.1.6 - '@contentful/rich-text-types': - specifier: 'catalog:' - version: 17.2.5 - contentful: - specifier: 'catalog:' - version: 11.8.5 + '@contentful/optimization-web': + specifier: workspace:* + version: link:../../platforms/javascript/web/dist + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 express: specifier: 'catalog:' version: 5.1.0 @@ -157,8 +154,11 @@ importers: version: 2.8.1 devDependencies: '@playwright/test': - specifier: ^1.55.1 - version: 1.56.0 + specifier: ^1.57.0 + version: 1.57.0 + '@types/cookie-parser': + specifier: 1.4.7 + version: 1.4.7 '@types/express': specifier: 'catalog:' version: 5.0.3 @@ -168,6 +168,12 @@ importers: '@types/qs': specifier: 'catalog:' version: 6.14.0 + '@types/supertest': + specifier: 'catalog:' + version: 6.0.3 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 3.2.4(vitest@3.2.4(@types/node@24.2.0)(happy-dom@20.0.2)(jiti@2.5.1)(msw@2.10.5(@types/node@24.2.0)(typescript@5.9.2))(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1)) dotenv: specifier: 'catalog:' version: 17.2.2 @@ -177,24 +183,33 @@ importers: rimraf: specifier: 'catalog:' version: 6.0.1 + supertest: + specifier: 'catalog:' + version: 7.1.3 tsx: specifier: 'catalog:' version: 4.20.3 typescript: specifier: ^5.8.3 version: 5.9.2 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@24.2.0)(happy-dom@20.0.2)(jiti@2.5.1)(msw@2.10.5(@types/node@24.2.0)(typescript@5.9.2))(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1) implementations/node-ssr: dependencies: '@contentful/optimization-node': specifier: workspace:* version: link:../../platforms/javascript/node/dist - '@contentful/optimization-web': - specifier: workspace:* - version: link:../../platforms/javascript/web/dist - cookie-parser: - specifier: ^1.4.7 - version: 1.4.7 + '@contentful/rich-text-html-renderer': + specifier: 'catalog:' + version: 17.1.6 + '@contentful/rich-text-types': + specifier: 'catalog:' + version: 17.2.5 + contentful: + specifier: 'catalog:' + version: 11.8.5 express: specifier: 'catalog:' version: 5.1.0 @@ -209,11 +224,8 @@ importers: version: 2.8.1 devDependencies: '@playwright/test': - specifier: ^1.55.1 - version: 1.56.0 - '@types/cookie-parser': - specifier: 1.4.7 - version: 1.4.7 + specifier: ^1.57.0 + version: 1.57.0 '@types/express': specifier: 'catalog:' version: 5.0.3 @@ -223,12 +235,6 @@ importers: '@types/qs': specifier: 'catalog:' version: 6.14.0 - '@types/supertest': - specifier: 'catalog:' - version: 6.0.3 - '@vitest/coverage-v8': - specifier: 'catalog:' - version: 3.2.4(vitest@3.2.4(@types/node@24.2.0)(happy-dom@20.0.2)(jiti@2.5.1)(msw@2.10.5(@types/node@24.2.0)(typescript@5.9.2))(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1)) dotenv: specifier: 'catalog:' version: 17.2.2 @@ -238,18 +244,12 @@ importers: rimraf: specifier: 'catalog:' version: 6.0.1 - supertest: - specifier: 'catalog:' - version: 7.1.3 tsx: specifier: 'catalog:' version: 4.20.3 typescript: specifier: ^5.8.3 version: 5.9.2 - vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/node@24.2.0)(happy-dom@20.0.2)(jiti@2.5.1)(msw@2.10.5(@types/node@24.2.0)(typescript@5.9.2))(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1) implementations/react-native: dependencies: @@ -364,8 +364,8 @@ importers: version: link:../../platforms/javascript/web/dist devDependencies: '@playwright/test': - specifier: ^1.55.1 - version: 1.56.0 + specifier: ^1.57.0 + version: 1.57.0 '@types/node': specifier: ^24.0.13 version: 24.2.0 @@ -2110,8 +2110,8 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.56.0': - resolution: {integrity: sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==} + '@playwright/test@1.57.0': + resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} engines: {node: '>=18'} hasBin: true @@ -6419,13 +6419,13 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} - playwright-core@1.56.0: - resolution: {integrity: sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==} + playwright-core@1.57.0: + resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} engines: {node: '>=18'} hasBin: true - playwright@1.56.0: - resolution: {integrity: sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==} + playwright@1.57.0: + resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} engines: {node: '>=18'} hasBin: true @@ -9601,9 +9601,9 @@ snapshots: '@pkgr/core@0.2.9': {} - '@playwright/test@1.56.0': + '@playwright/test@1.57.0': dependencies: - playwright: 1.56.0 + playwright: 1.57.0 '@pm2/agent@2.1.1': dependencies: @@ -9936,7 +9936,9 @@ snapshots: transitivePeerDependencies: - '@babel/core' - '@babel/preset-env' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.76.9': {} @@ -15178,11 +15180,11 @@ snapshots: dependencies: find-up: 4.1.0 - playwright-core@1.56.0: {} + playwright-core@1.57.0: {} - playwright@1.56.0: + playwright@1.57.0: dependencies: - playwright-core: 1.56.0 + playwright-core: 1.57.0 optionalDependencies: fsevents: 2.3.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c541d92f..2e79c33e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,7 +7,7 @@ packages: catalog: '@contentful/rich-text-html-renderer': ^17.1.6 '@contentful/rich-text-types': ^17.2.5 - '@playwright/test': ^1.55.1 + '@playwright/test': ^1.57.0 '@preact/signals-core': ^1.12.1 '@types/express': ^5.0.3 '@types/node': ^24.0.13 diff --git a/typedoc.json b/typedoc.json index bba0e7b4..2bfbdf50 100644 --- a/typedoc.json +++ b/typedoc.json @@ -1,13 +1,14 @@ { "$schema": "https://typedoc.org/schema.json", - "name": "Multiplatform SDK Monorepo Example", + "name": "Contentful Personalization & Analytics", "includeVersion": false, "headings": { "document": false, "readme": false }, "entryPointStrategy": "packages", - "entryPoints": ["platforms/javascript/*"], + "entryPoints": ["platforms/javascript/*", "universal/*"], + "projectDocuments": ["CONTRIBUTING.md"], "packageOptions": { "includeVersion": true, "entryPoints": ["src/index.ts"] diff --git a/universal/api-client/README.md b/universal/api-client/README.md index e69de29b..175ae531 100644 --- a/universal/api-client/README.md +++ b/universal/api-client/README.md @@ -0,0 +1,282 @@ +

+ + Contentful Logo + +

+ +

Contentful Personalization & Analytics

+ +

API Client

+ +
+ +[Readme](./README.md) · [Reference](https://contentful.github.io/optimization) · +[Contributing](/CONTRIBUTING.md) + +
+ +The Contentful Optimization API Client Library provides methods for interfacing with Contentful's +Experience and Insights APIs, which serve its Personalization and Analytics products. + +> [!NOTE] +> +> In the future, the Experience and Insights APIs may be combined behind an Optimization API + +
+ Table of Contents + + +- [Getting Started](#getting-started) +- [Configuration](#configuration) + - [Top-level Configuration Options](#top-level-configuration-options) + - [Fetch Options](#fetch-options) + - [Analytics Options](#analytics-options) + - [Personalization Options](#personalization-options) +- [Working With the APIs](#working-with-the-apis) + - [Experience API](#experience-api) + - [Get Profile data](#get-profile-data) + - [Create a New Profile](#create-a-new-profile) + - [Update an Existing Profile](#update-an-existing-profile) + - [Upsert a Profile](#upsert-a-profile) + - [Upsert Many Profiles](#upsert-many-profiles) + - [Insights API](#insights-api) + - [Send Batch Events](#send-batch-events) +- [Event Builder](#event-builder) + - [Event Builder Configuration](#event-builder-configuration) + - [Event Builder Configured Methods](#event-builder-configured-methods) + + +
+ +## Getting Started + +Install using an NPM-compatible package manager, pnpm for example: + +```sh +pnpm install @contentful/optimization-api-client +``` + +Import the API client; both CJS and ESM module systems are supported, ESM preferred: + +```ts +import ApiClient from '@contentful/optimization-api-client' +``` + +Configure and initialize the API Client: + +```ts +const client = new ApiClient({ clientId: 'abc123' }) +``` + +## Configuration + +### Top-level Configuration Options + +| Option | Required? | Default | Description | +| ----------------- | --------- | ----------------------------- | --------------------------------------------------------------------- | +| `analytics` | No | See "Analytics Options" | Configuration specific to the Analytics/Insights API | +| `clientId` | Yes | N/A | The Ninetailed API Key which can be found in the Ninetailed Admin app | +| `environment` | No | `'main'` | The Ninetailed environment configured in the Ninetailed Admin app | +| `fetchOptions` | No | See "Fetch Options" | Configuration for Fetch timeout and retry functionality | +| `personalization` | No | See "Personalization Options" | Configuration specific to the Personalization/Experience API | + +### Fetch Options + +Fetch options allow for configuration of both a Fetch API-compatible fetch method and the +retry/timeout logic integrated into the Optimization API Client. Specify the `fetchMethod` when the +host application environment does not offer a `fetch` method that is compatible with the standard +Fetch API in its global scope. + +| Option | Required? | Default | Description | +| ------------------ | --------- | ----------- | --------------------------------------------------------------------- | +| `fetchMethod` | No | `undefined` | Signature of a fetch method used by the API clients | +| `intervalTimeout` | No | `0` | Delay (in milliseconds) between retry attempts | +| `onFailedAttempt` | No | `undefined` | Callback invoked whenever a retry attempt fails | +| `onRequestTimeout` | No | `undefined` | Callback invoked when a request exceeds the configured timeout | +| `requestTimeout` | No | `3000` | Maximum time (in milliseconds) to wait for a response before aborting | +| `retries` | No | `1` | Maximum number of retry attempts | + +Configuration method signatures: + +- `fetchMethod`: `(url: string \| URL, init: RequestInit) => Promise` +- `onFailedAttempt` and `onRequestTimeout`: `(options: FetchMethodCallbackOptions) => void` + +### Analytics Options + +| Option | Required? | Default | Description | +| --------------- | --------- | ------------------------------------------ | ------------------------------------------------------------------------ | +| `baseUrl` | No | `'https://ingest.insights.ninetailed.co/'` | Base URL for the Insights API | +| `beaconHandler` | No | `undefined` | Handler used to enqueue events via the Beacon API or a similar mechanism | + +Configuration method signatures: + +- `beaconHandler`: `(url: string | URL, data: BatchInsightsEventArray) => boolean` + +### Personalization Options + +| Option | Required? | Default | Description | +| ----------------- | --------- | ------------------------------------- | ------------------------------------------------------------------- | +| `baseUrl` | No | `'https://experience.ninetailed.co/'` | Base URL for the Experience API | +| `enabledFeatures` | No | `['ip-enrichment', 'location']` | Enabled features which the API may use for each request | +| `ip` | No | `undefined` | IP address to override the API behavior for IP analysis | +| `locale` | No | `'en-US'` (in API) | Locale used to translate `location.city` and `location.country` | +| `plainText` | No | `false` | Sends performance-critical endpoints in plain text | +| `preflight` | No | `false` | Instructs the API to aggregate a new profile state but not store it | + +> [!NOTE] +> +> All options except `baseUrl` may also be provided on a per-request basis. + +## Working With the APIs + +### Experience API + +Experience API methods are scoped to the client's `personalization` member. All singular +personalization methods return a `Promise` that resolves with the following data: + +```json +{ + "profile": { + /* User profile data */ + }, + "personalizations": [ + { + /* Personalization/experience configuration for the associated profile */ + } + ], + "changes": [ + { + /* Similar to `personalizations` but currently used for Custom Flags */ + } + ] +} +``` + +#### Get Profile data + +```ts +const client = new ApiClient({ clientId: 'abc123' }) +const { profile } = await client.personalization.getProfile('profile-123', { locale: 'de-DE' }) +``` + +#### Create a New Profile + +```ts +const client = new ApiClient({ clientId: 'abc123' }) +const { profile } = await client.personalization.createProfile({ events: [...] }, { locale: 'de-DE' }) +``` + +#### Update an Existing Profile + +```ts +const client = new ApiClient({ clientId: 'abc123' }) +const { profile } = await client.personalization.updateProfile( + { + profileId: 'profile-123', + events: [...], + }, + { locale: 'de-DE' } +) +``` + +#### Upsert a Profile + +```ts +const client = new ApiClient({ clientId: 'abc123' }) +const { profile } = await client.personalization.upsertProfile( + { + profileId, + events: [...], + }, + { locale: 'de-DE' } +) +``` + +#### Upsert Many Profiles + +The `upsertManyProfiles` method returns a `Promise` that resolves with an array of user profiles. +Each event should have an additional `anonymousId` property set to the associated anonymous ID or +profile ID. + +```ts +const client = new ApiClient({ clientId: 'abc123' }) +const profiles = await client.personalization.upsertManyProfiles( + { events: [...]}, + { locale: 'de-DE' }, +) +``` + +### Insights API + +Insights API methods are scoped to the client's `analytics` member. All analytics methods return a +`Promise` that resolves to `void` (no value). + +#### Send Batch Events + +```ts +const client = new ApiClient({ clientId: 'abc123' }) +await client.analytics.sendBatchEvents([ + { + profile: { id: 'abc-123', ... }, + events: [{ type: 'track', ... }], + } +]) +``` + +## Event Builder + +The Event Builder is a helper class that assists in constructing valid events for submission to the +Experience and Insights APIs. + +### Event Builder Configuration + +Event Builder configuration options assist in adding contextual data to each event created by a +builder instance. + +| Option | Required? | Default | Description | +| ------------------- | --------- | ------------------------------- | ---------------------------------------------------------------------------------- | +| `app` | No | `undefined` | The application definition used to attribute events to a specific consumer app | +| `channel` | Yes | N/A | The channel that identifies where events originate from (e.g. `'web'`, `'mobile'`) | +| `library` | Yes | N/A | The client library metadata that is attached to all events | +| `getLocale` | No | `() => 'en-US'` | Function used to resolve the locale for outgoing events | +| `getPageProperties` | No | `() => DEFAULT_PAGE_PROPERTIES` | Function that returns the current page properties | +| `getUserAgent` | No | `() => undefined` | Function used to obtain the current user agent string when applicable | + +The `get*` functions are most useful in stateful environments. Stateless environments should set the +related data directly via Event Builder method arguments. + +The `channel` option may contain one of the following values: + +- `web` +- `mobile` +- `server` + +Configuration method signatures: + +- `getLocale`: `() => string | undefined` +- `getPageProperties`: + + ```ts + () => { + path: string, + query: Record, + referrer: string, + search: string, + title?: string, + url: string + } + ``` + +- `getUserAgent`: `() => string | undefined` + +#### Event Builder Configured Methods + +- `buildComponentView`: Builds a component view event payload for a Contentful entry-based component +- `buildFlagView`: Builds a component view payload event for a Custom Flag component +- `buildIdentify`: Builds an identify event payload to associate a user ID with traits +- `buildPageView`: Builds a page view event payload +- `buildTrack`: Builds a track event payload for arbitrary user actions + +See the +[Event Builder documentation](../classes/_contentful_optimization-api-client.EventBuilder.html) for +more information regarding arguments and return values. diff --git a/universal/api-client/package.json b/universal/api-client/package.json index bbd5b9a9..092f2fd9 100644 --- a/universal/api-client/package.json +++ b/universal/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@contentful/optimization-api-client", - "version": "1.0.0", + "version": "0.0.0", "license": "MIT", "type": "commonjs", "main": "./dist/index.cjs", diff --git a/universal/api-client/src/ApiClient.ts b/universal/api-client/src/ApiClient.ts index 7c739ab9..5ea1ff03 100644 --- a/universal/api-client/src/ApiClient.ts +++ b/universal/api-client/src/ApiClient.ts @@ -2,16 +2,79 @@ import type { ApiConfig, GlobalApiConfigProperties } from './ApiClientBase' import ExperienceApiClient, { type ExperienceApiClientConfig } from './experience' import InsightsApiClient, { type InsightsApiClientConfig } from './insights' +/** + * Configuration for the high-level {@link ApiClient}. + * + * @public + */ export interface ApiClientConfig extends Pick { + /** + * Configuration for the personalization (Experience) API client. + * + * @remarks + * Any properties shared with {@link ApiConfig} are taken from the top-level + * config and overridden by this object when specified. + */ personalization?: Omit + + /** + * Configuration for the analytics (Insights) API client. + * + * @remarks + * Any properties shared with {@link ApiConfig} are taken from the top-level + * config and overridden by this object when specified. + */ analytics?: Omit } +/** + * Aggregated API client providing access to Experience and Insights APIs. + * + * @public + * + * @remarks + * This client encapsulates shared configuration and exposes dedicated + * sub-clients for personalization and analytics use cases. + * + * @example + * ```ts + * const client = new ApiClient({ + * clientId: 'org-id', + * environment: 'main', + * preview: false, + * personalization: { + * // experience-specific overrides + * }, + * analytics: { + * // insights-specific overrides + * }, + * }) + * + * const profile = await client.experience.getProfile('profile-id') + * const batch = await client.insights.upsertManyProfiles({ events: batchEvents }) + * ``` + */ export default class ApiClient { + /** + * Shared configuration applied to both Experience and Insights clients. + */ readonly config: ApiConfig + + /** + * Client for personalization and experience-related operations. + */ readonly experience: ExperienceApiClient + + /** + * Client for analytics and insights-related operations. + */ readonly insights: InsightsApiClient + /** + * Creates a new aggregated {@link ApiClient} instance. + * + * @param config - Global API client configuration with optional per-client overrides. + */ constructor(config: ApiClientConfig) { const { personalization, analytics, ...apiConfig } = config diff --git a/universal/api-client/src/ApiClientBase.ts b/universal/api-client/src/ApiClientBase.ts index 28463393..15f2d122 100644 --- a/universal/api-client/src/ApiClientBase.ts +++ b/universal/api-client/src/ApiClientBase.ts @@ -1,35 +1,134 @@ import { logger } from 'logger' import Fetch, { type FetchMethod, type ProtectedFetchMethodOptions } from './fetch' +/** + * Default Contentful environment used when none is explicitly provided. + * + * @internal + */ +const DEFAULT_ENVIRONMENT = 'main' + +/** + * Configuration options for API clients extending {@link ApiClientBase}. + * + * @public + */ export interface ApiConfig { + /** + * Base URL for the API. + * + * @remarks + * When omitted, the concrete client is expected to construct full URLs + * internally. + */ baseUrl?: string + + /** + * Contentful environment identifier. + * + * @remarks + * Defaults to `main` when not provided. + */ environment?: string + + /** + * Options used to configure the underlying protected fetch method. + * + * @remarks + * `apiName` is derived from the client name and must not be provided here. + */ fetchOptions?: Omit + + /** + * Client identifier used for authentication or tracking. + */ clientId: string - preview?: boolean } -export type GlobalApiConfigProperties = 'environment' | 'fetchOptions' | 'clientId' | 'preview' - -const DEFAULT_ENVIRONMENT = 'main' +/** + * Properties that may be shared between global and per-client API configuration. + * + * @public + */ +export type GlobalApiConfigProperties = 'environment' | 'fetchOptions' | 'clientId' +/** + * Base class for API clients that provides shared configuration and error logging. + * + * @internal + * + * @remarks + * This abstract class is intended for internal use within the package and + * should not be treated as part of the public API surface. + * + * Concrete API clients should extend this class to inherit consistent logging + * behavior and fetch configuration. + * + * @example + * ```ts + * interface MyClientConfig extends ApiConfig { + * // additional config + * } + * + * class MyClient extends ApiClientBase { + * constructor(config: MyClientConfig) { + * super('MyClient', config) + * } + * + * async getSomething() { + * const response = await this.fetch('https://example.com', { method: 'GET' }) + * return response.json() + * } + * } + * ``` + */ abstract class ApiClientBase { + /** + * Name of the API client, used in log messages and as the `apiName` for fetch. + */ protected readonly name: string + + /** + * Client identifier used for authentication or tracking. + */ protected readonly clientId: string + + /** + * Contentful environment associated with this client. + */ protected readonly environment: string - protected readonly preview?: boolean + /** + * Protected fetch method used by the client to perform HTTP requests. + */ protected readonly fetch: FetchMethod - constructor(name: string, { fetchOptions, clientId, environment, preview }: ApiConfig) { + /** + * Creates a new API client base instance. + * + * @param name - Human-readable name of the client (used for logging and `apiName`). + * @param config - Configuration options for the client. + */ + constructor(name: string, { fetchOptions, clientId, environment }: ApiConfig) { this.clientId = clientId this.environment = environment ?? DEFAULT_ENVIRONMENT this.name = name - this.preview = Boolean(preview) this.fetch = Fetch.create({ ...(fetchOptions ?? {}), apiName: name }) } + /** + * Logs errors that occur during API requests with standardized messages. + * + * @param error - The error thrown by the underlying operation. + * @param options - Additional metadata about the request. + * @param options.requestName - Human-readable name of the request operation. + * + * @protected + * + * @remarks + * Abort errors are logged at `warn` level and other errors at `error` level. + */ protected logRequestError(error: unknown, { requestName }: { requestName: string }): void { if (error instanceof Error) { if (error.name === 'AbortError') { diff --git a/universal/api-client/src/builders/EventBuilder.ts b/universal/api-client/src/builders/EventBuilder.ts index e4d4dd55..0e192424 100644 --- a/universal/api-client/src/builders/EventBuilder.ts +++ b/universal/api-client/src/builders/EventBuilder.ts @@ -16,13 +16,86 @@ import { import { merge } from 'es-toolkit' import * as z from 'zod/mini' +/** + * Configuration options for creating an {@link EventBuilder} instance. + * + * @public + * @remarks + * The configuration is typically provided by the host application to adapt + * event payloads to the runtime environment (browser, framework, etc.). + * + * @example + * ```ts + * const builder = new EventBuilder({ + * app: { name: 'my-app', version: '1.0.0' }, + * channel: 'web', + * library: { name: '@contentful/optimization-sdk', version: '1.2.3' }, + * getLocale: () => navigator.language, + * getPageProperties: () => ({ + * path: window.location.pathname, + * url: window.location.href, + * title: document.title, + * query: {}, + * referrer: document.referrer, + * search: window.location.search, + * }), + * }) + * ``` + */ export interface EventBuilderConfig { + /** + * The application definition used to attribute events to a specific consumer app. + * + * @remarks + * When not provided, events will not contain app metadata in their context. + */ app?: App + + /** + * The channel that identifies where events originate from (e.g. web, mobile). + * + * @see {@link Channel} + */ channel: Channel + + /** + * The client library metadata that is attached to all events. + * + * @remarks + * This is typically used to record the library name and version. + */ library: Library - getAnonymousId?: () => string | undefined + + /** + * Function used to resolve the locale for outgoing events. + * + * @remarks + * If not provided, the builder falls back to the default `'en-US'`. Locale + * values supplied directly as arguments to event builder methods take + * precedence. + * + * @returns The locale string (e.g. `'en-US'`), or `undefined` if unavailable. + */ getLocale?: () => string | undefined + + /** + * Function that returns the current page properties. + * + * @remarks + * Page properties are currently added to the context of all events, as well + * as the `properties` of the page event. When specified, all properties of + * the `Page` type are required, but may contain empty values. + * + * @returns A {@link Page} object containing information about the current page. + * @see {@link Page} + */ getPageProperties?: () => Page + + /** + * Function used to obtain the current user agent string when applicable. + * + * @returns A user agent string, or `undefined` if unavailable. + */ getUserAgent?: () => string | undefined } @@ -33,32 +106,88 @@ const UniversalEventBuilderArgs = z.object({ page: z.optional(Page), userAgent: z.optional(z.string()), }) + +/** + * Arguments used to construct the universal (shared) portion of all events. + * + * @public + */ export type UniversalEventBuilderArgs = z.infer const ComponentViewBuilderArgs = z.extend(UniversalEventBuilderArgs, { componentId: z.string(), experienceId: z.optional(z.string()), variantIndex: z.optional(z.number()), + sticky: z.optional(z.boolean()), }) + +/** + * Arguments for constructing component view events. + * + * @public + */ export type ComponentViewBuilderArgs = z.infer const IdentifyBuilderArgs = z.extend(UniversalEventBuilderArgs, { traits: z.optional(Traits), userId: z.string(), }) + +/** + * Arguments for constructing identify events. + * + * @public + * @remarks + * Traits are merged by the API; only specified properties may be overwritten. + */ export type IdentifyBuilderArgs = z.infer const PageViewBuilderArgs = z.extend(UniversalEventBuilderArgs, { properties: z.optional(z.partial(Page)), }) + +/** + * Arguments for constructing page view events. + * + * @public + * @remarks + * Any properties passed here are merged with the base page properties from + * {@link EventBuilderConfig.getPageProperties}. + */ export type PageViewBuilderArgs = z.infer const TrackBuilderArgs = z.extend(UniversalEventBuilderArgs, { event: z.string(), properties: z.optional(z.prefault(Properties, {})), }) + +/** + * Arguments for constructing track events. + * + * @public + */ export type TrackBuilderArgs = z.infer +/** + * Default page properties used when no explicit page information is available. + * + * @public + * + * @defaultValue + * ```ts + * { + * path: '', + * query: {}, + * referrer: '', + * search: '', + * title: '', + * url: '', + * } + * ``` + * + * @remarks + * Values are required by the API; values may not be `undefined`. Empty values are valid. + */ export const DEFAULT_PAGE_PROPERTIES = { path: '', query: {}, @@ -68,20 +197,81 @@ export const DEFAULT_PAGE_PROPERTIES = { url: '', } +/** + * Internal helper class for building analytics and personalization events. + * + * @remarks + * This class coordinates configuration and argument validation to produce + * strongly-typed event payloads compatible with + * `@contentful/optimization-api-schemas`. + * + * @public + */ class EventBuilder { + /** + * Application metadata attached to each event. + * + * @internal + */ app?: App + + /** + * Channel value attached to each event. + * + * @internal + */ channel: Channel + + /** + * Library metadata attached to each event. + * + * @internal + */ library: Library - getAnonymousId: () => string | undefined + + /** + * Function that provides the locale when available. + * + * @internal + */ getLocale: () => string | undefined + + /** + * Function that provides baseline page properties. + * + * @internal + */ getPageProperties: () => Page + + /** + * Function that provides the user agent string when available. + * + * @internal + */ getUserAgent: () => string | undefined + /** + * Creates a new {@link EventBuilder} instance. + * + * @param config - Configuration used to customize event payloads. + * + * @internal + * @remarks + * Callers are expected to reuse a single instance when possible to avoid + * repeatedly reconfiguring the builder. + * + * @example + * ```ts + * const builder = new EventBuilder({ + * channel: 'web', + * library: { name: '@contentful/optimization-sdk', version: '1.0.0' }, + * }) + * ``` + */ constructor({ app, channel, library, - getAnonymousId, getLocale, getPageProperties, getUserAgent, @@ -89,12 +279,23 @@ class EventBuilder { this.app = app this.channel = channel this.library = library - this.getAnonymousId = getAnonymousId ?? (() => undefined) this.getLocale = getLocale ?? (() => 'en-US') this.getPageProperties = getPageProperties ?? (() => DEFAULT_PAGE_PROPERTIES) this.getUserAgent = getUserAgent ?? (() => undefined) } + /** + * Builds the universal event properties shared across all event types. + * + * @param args - Arguments overriding the default context values. + * @returns A fully populated {@link UniversalEventProperties} object. + * + * @protected + * + * @remarks + * This method is used internally by the specific event-builder methods + * (e.g. {@link EventBuilder.buildPageView}). + */ protected buildUniversalEventProperties({ campaign = {}, locale, @@ -123,6 +324,23 @@ class EventBuilder { } } + /** + * Builds a component view event payload for a Contentful entry-based component. + * + * @param args - {@link ComponentViewBuilderArgs} arguments describing the component view. + * @returns A {@link ComponentViewEvent} describing the view. + * + * @public + * + * @example + * ```ts + * const event = builder.buildComponentView({ + * componentId: 'entry-123', + * experienceId: 'personalization-123', + * variantIndex: 1, + * }) + * ``` + */ buildComponentView(args: ComponentViewBuilderArgs): ComponentViewEvent { const { componentId, experienceId, variantIndex, ...universal } = ComponentViewBuilderArgs.parse(args) @@ -137,6 +355,26 @@ class EventBuilder { } } + /** + * Builds a component view event payload for a Custom Flag component. + * + * @param args - {@link ComponentViewBuilderArgs} arguments describing the Custom Flag view. + * @returns A {@link ComponentViewEvent} describing the view. + * + * @public + * + * @remarks + * This is a specialized variant of {@link EventBuilder.buildComponentView} + * that sets `componentType` to `'Variable'`. + * + * @example + * ```ts + * const event = builder.buildFlagView({ + * componentId: 'feature-flag-key', + * experienceId: 'personalization-123', + * }) + * ``` + */ buildFlagView(args: ComponentViewBuilderArgs): ComponentViewEvent { return { ...this.buildComponentView(args), @@ -144,6 +382,27 @@ class EventBuilder { } } + /** + * Builds an identify event payload to associate a user ID with traits. + * + * @param args - {@link IdentifyBuilderArgs} arguments describing the identified user. + * @returns An {@link IdentifyEvent} payload. + * + * @public + * + * @remarks + * - Traits are merged by the API; only specified properties may be overwritten. + * - The User ID is consumer-specified and should not contain the value of any + * ID generated by the Experience API. + * + * @example + * ```ts + * const event = builder.buildIdentify({ + * userId: 'user-123', + * traits: { plan: 'pro' }, + * }) + * ``` + */ buildIdentify(args: IdentifyBuilderArgs): IdentifyEvent { const { traits = {}, userId, ...universal } = IdentifyBuilderArgs.parse(args) @@ -155,6 +414,30 @@ class EventBuilder { } } + /** + * Builds a page view event payload. + * + * @param args - Optional {@link PageViewBuilderArgs} overrides for the page view event. + * @returns A {@link PageViewEvent} payload. + * + * @public + * + * @remarks + * Page properties are created by merging: + * 1. The base page properties from {@link EventBuilderConfig.getPageProperties}, and + * 2. The partial `properties` argument passed in. + * + * The title always falls back to {@link DEFAULT_PAGE_PROPERTIES}.title when undefined. + * + * @example + * ```ts + * const event = builder.buildPageView({ + * properties: { + * title: 'Homepage', + * }, + * }) + * ``` + */ buildPageView(args: PageViewBuilderArgs = {}): PageViewEvent { const { properties = {}, ...universal } = PageViewBuilderArgs.parse(args) @@ -175,6 +458,22 @@ class EventBuilder { } } + /** + * Builds a track event payload for arbitrary user actions. + * + * @param args - {@link TrackBuilderArgs} arguments describing the tracked event. + * @returns A {@link TrackEvent} payload. + * + * @public + * + * @example + * ```ts + * const event = builder.buildTrack({ + * event: 'button_clicked', + * properties: { id: 'primary-cta', location: 'hero' }, + * }) + * ``` + */ buildTrack(args: TrackBuilderArgs): TrackEvent { const { event, properties = {}, ...universal } = TrackBuilderArgs.parse(args) diff --git a/universal/api-client/src/experience/ExperienceApiClient.ts b/universal/api-client/src/experience/ExperienceApiClient.ts index 5b3e1e97..356b06ca 100644 --- a/universal/api-client/src/experience/ExperienceApiClient.ts +++ b/universal/api-client/src/experience/ExperienceApiClient.ts @@ -11,74 +11,158 @@ import { import { logger } from 'logger' import ApiClientBase, { type ApiConfig } from '../ApiClientBase' +/** + * Default base URL for the Experience API. + * + * @public + */ +export const EXPERIENCE_BASE_URL = 'https://experience.ninetailed.co/' + +/** + * Feature flags supported by the Experience API. + */ type Feature = 'ip-enrichment' | 'location' +/** + * Options that control how requests to the Experience API are handled. + */ interface RequestOptions { /** - * Activated features (e.g. "ip-enrichment") which the API should use for this request. + * Enabled features (for example, `"ip-enrichment"`) which the API should use for this request. + * + * @remarks + * When omitted, a default set of features may be applied. */ enabledFeatures?: Feature[] /** - * A ip address to override the API behavior for ip analysis (if used/activated) - * This is commonly used in ESR or SSR environments, as the API would use the Server IP otherwise + * IP address to override the API behavior for IP analysis. + * + * @remarks + * Commonly used in ESR or SSR environments, as the API would otherwise use + * the server IP. */ ip?: string /** - * The locale parameter determines the language to which the location.city & location.country will get translated + * Locale used to translate `location.city` and `location.country`. + * + * @remarks + * When omitted, a server-side default may be used. */ locale?: string /** - * The Ninetailed API accepts the performance critical endpoints in plaintext. - * By sending plaintext no CORS preflight request is needed. - * This way the "real" request is sent out much faster. + * When `true`, sends performance-critical endpoints in plain text. + * + * @remarks + * The Ninetailed API accepts certain endpoints in plain text to avoid CORS + * preflight requests, which can improve performance in browser environments. */ plainText?: boolean /** - * Setting the preflight mode will make the api aggregate a new state o the profile, - * but not store the state. - * This is commonly used in ESR or SSR environments + * When `true`, instructs the API to aggregate a new profile state but not store it. + * + * @remarks + * This is commonly used in ESR or SSR environments where you want to + * preview the result without persisting changes. */ preflight?: boolean } +/** + * Internal options for profile mutation requests. + * + * @internal + */ interface ProfileMutationRequestOptions { url: string body: unknown options: RequestOptions } +/** + * Parameters used when creating a profile. + */ interface CreateProfileParams { + /** + * Events used to aggregate the profile state. + */ events: ExperienceEventArray } +/** + * Parameters used when updating an existing profile. + */ interface UpdateProfileParams extends CreateProfileParams { + /** + * ID of the profile to update. + */ profileId: string } +/** + * Parameters used when creating or updating a profile. + */ interface UpsertProfileParams extends CreateProfileParams { + /** + * Optional ID of the profile; when omitted, a new profile is created. + */ profileId?: string } +/** + * Parameters used when performing a batch profile update. + */ interface BatchUpdateProfileParams { + /** + * Batch of events to process. + */ events: BatchExperienceEventArray } +/** + * Configuration for {@link ExperienceApiClient}. + */ export interface ExperienceApiClientConfig extends ApiConfig, RequestOptions {} -export const EXPERIENCE_BASE_URL = 'https://experience.ninetailed.co/' - +/** + * Client for interacting with the Experience API. + * + * @public + * + * @remarks + * This client is responsible for reading and mutating Ninetailed profiles + * using the Experience API. + * + * @example + * ```ts + * const client = new ExperienceApiClient({ + * clientId: 'org-id', + * environment: 'main', + * }) + * + * const profile = await client.getProfile('profile-id') + * ``` + */ export default class ExperienceApiClient extends ApiClientBase { + /** + * Base URL used for Experience API requests. + */ protected readonly baseUrl: string + private readonly enabledFeatures?: RequestOptions['enabledFeatures'] private readonly ip?: RequestOptions['ip'] private readonly locale?: RequestOptions['locale'] private readonly plainText?: RequestOptions['plainText'] private readonly preflight?: RequestOptions['preflight'] + /** + * Creates a new {@link ExperienceApiClient} instance. + * + * @param config - Configuration for the Experience API client. + */ constructor(config: ExperienceApiClientConfig) { super('Experience', config) @@ -93,6 +177,23 @@ export default class ExperienceApiClient extends ApiClientBase { this.preflight = preflight } + /** + * Retrieves a profile by ID. + * + * @param id - The profile ID to retrieve. + * @param options - Optional request options. `preflight` and `plainText` are not allowed here. + * @returns The current optimization data for the profile. + * + * @throws {@link Error} + * Thrown if `id` is missing or the underlying request fails. + * + * @example + * ```ts + * const profile = await client.getProfile('profile-id', { + * locale: 'en-US', + * }) + * ``` + */ public async getProfile( id: string, options: Omit = {}, @@ -130,6 +231,14 @@ export default class ExperienceApiClient extends ApiClientBase { } } + /** + * Sends a POST request to mutate a profile or profiles. + * + * @param request - Mutation request options including URL, body, and request options. + * @returns The raw {@link Response} from the underlying fetch. + * + * @internal + */ private async makeProfileMutationRequest({ url, body, @@ -143,9 +252,21 @@ export default class ExperienceApiClient extends ApiClientBase { } /** - * Creates a profile and returns it. - * Use the given profileId for subsequent update requests. - * The events will be used to aggregate the new Profile state. + * Creates a profile and returns the resulting optimization data. + * + * @param params - Parameters containing the events to aggregate into the profile. + * @param options - Optional request options. + * @returns The optimization data for the newly created profile. + * + * @remarks + * The returned profile ID can be used for subsequent update requests. + * + * @example + * ```ts + * const data = await client.createProfile({ + * events: [{ type: 'identify', userId: 'user-123' }], + * }) + * ``` */ public async createProfile( { events }: CreateProfileParams, @@ -186,8 +307,22 @@ export default class ExperienceApiClient extends ApiClientBase { } /** - * Updates a profile with the given profileId. - * The events will be used to aggregate the new Profile state. + * Updates an existing profile with the given profile ID. + * + * @param params - Parameters including the profile ID and events. + * @param options - Optional request options. + * @returns The updated optimization data for the profile. + * + * @throws {@link Error} + * Thrown if `profileId` is missing or the underlying request fails. + * + * @example + * ```ts + * const data = await client.updateProfile({ + * profileId: 'profile-id', + * events: [{ type: 'track', event: 'viewed_video' }], + * }) + * ``` */ public async updateProfile( { profileId, events }: UpdateProfileParams, @@ -229,6 +364,22 @@ export default class ExperienceApiClient extends ApiClientBase { } } + /** + * Creates or updates a profile depending on whether a `profileId` is provided. + * + * @param params - Parameters including optional profile ID and events. + * @param options - Optional request options. + * @returns The resulting optimization data. + * + * @example + * ```ts + * // Create + * await client.upsertProfile({ events }) + * + * // Update + * await client.upsertProfile({ profileId: 'profile-id', events }) + * ``` + */ public async upsertProfile( { profileId, events }: UpsertProfileParams, options?: RequestOptions, @@ -241,11 +392,27 @@ export default class ExperienceApiClient extends ApiClientBase { } /** - * Sends multiple events to the Ninetailed API. - * Every events needs to have a anonymous ID. - * Profiles will get created or updated according to the set anonymous ID. + * Sends multiple events to the Ninetailed Experience API to upsert many profiles. + * + * @param params - Parameters containing the batch of events. + * @param options - Optional request options. + * @returns The list of profiles affected by the batch operation. + * + * @remarks + * Every event must contain an anonymous ID. Profiles will be created or + * updated according to the anonymous ID. * * This method is intended to be used from server environments. + * + * @example + * ```ts + * const profiles = await client.upsertManyProfiles({ + * events: [ + * [{ type: 'identify', userId: 'user-1' }], + * [{ type: 'identify', userId: 'user-2' }], + * ], + * }) + * ``` */ public async upsertManyProfiles( { events }: BatchUpdateProfileParams, @@ -283,6 +450,15 @@ export default class ExperienceApiClient extends ApiClientBase { } } + /** + * Constructs a request URL with query parameters derived from request options. + * + * @param path - Path relative to the Experience API base URL. + * @param options - Request options that may influence query parameters. + * @returns The fully constructed URL as a string. + * + * @internal + */ private constructUrl(path: string, options: RequestOptions): string { const url = new URL(path, this.baseUrl) const locale = options.locale ?? this.locale @@ -299,6 +475,14 @@ export default class ExperienceApiClient extends ApiClientBase { return url.toString() } + /** + * Constructs request headers based on request options and default configuration. + * + * @param options - Request options that may influence headers. + * @returns A record of HTTP headers to send with the request. + * + * @internal + */ private constructHeaders({ ip = this.ip, plainText = this.plainText, @@ -318,6 +502,14 @@ export default class ExperienceApiClient extends ApiClientBase { return Object.fromEntries(headers) } + /** + * Constructs the `options` section of the request body for profile mutations. + * + * @param options - Request options that may specify enabled features. + * @returns Experience API body options including feature flags. + * + * @internal + */ private readonly constructBodyOptions = ({ enabledFeatures = this.enabledFeatures, }: RequestOptions): ExperienceRequestOptions => { diff --git a/universal/api-client/src/fetch/Fetch.ts b/universal/api-client/src/fetch/Fetch.ts index 9a1b6538..19d4d64d 100644 --- a/universal/api-client/src/fetch/Fetch.ts +++ b/universal/api-client/src/fetch/Fetch.ts @@ -1,20 +1,103 @@ import { createProtectedFetchMethod } from './createProtectedFetchMethod' +/** + * Signature of a fetch method used by the API clients. + * + * @param url - The request URL. + * @param init - Initialization options passed to `fetch`. + * @returns A promise that resolves with the {@link Response}. + * + * @public + * + * @remarks + * This abstraction allows the underlying implementation to be replaced, + * for example in tests or different runtime environments. + * + * @example + * ```ts + * const method: FetchMethod = async (url, init) => { + * return fetch(url, init) + * } + * ``` + */ export type FetchMethod = (url: string | URL, init: RequestInit) => Promise +/** + * Base options shared across fetch method factories. + * + * @public + */ export interface BaseFetchMethodOptions { + /** + * Human-readable name of the API being called. + * + * @remarks + * Used primarily for logging and error messages. + */ apiName?: string + + /** + * Custom fetch implementation to use instead of the global `fetch`. + * + * @remarks + * This is useful for providing polyfills, mocks, or instrumented fetch + * implementations. + */ fetchMethod?: FetchMethod } +/** + * Options passed to callback functions invoked by fetch wrappers. + * + * @public + * + * @remarks + * Not all fields are guaranteed to be present in all callback scenarios. + */ export interface FetchMethodCallbackOptions { + /** + * Name of the API associated with the request. + */ apiName?: string + + /** + * Error that caused the callback to be invoked, if available. + */ error?: Error + + /** + * The current attempt number (for retry callbacks). + */ attemptNumber?: number + + /** + * Number of retry attempts remaining (for retry callbacks). + */ retriesLeft?: number } +/** + * Namespace-like object providing factory methods for protected fetch functions. + * + * @public + */ const Fetch = { + /** + * Creates a fully protected fetch method with timeout and retry behavior. + * + * @example + * ```ts + * const fetchMethod = Fetch.create({ + * apiName: 'Optimization', + * requestTimeout: 3000, + * retries: 2, + * }) + * + * const response = await fetchMethod('https://example.com', { method: 'GET' }) + * ``` + * + * @see createProtectedFetchMethod + */ create: createProtectedFetchMethod, } diff --git a/universal/api-client/src/fetch/createProtectedFetchMethod.test.ts b/universal/api-client/src/fetch/createProtectedFetchMethod.test.ts index 4ae5cbf8..90b1e46d 100644 --- a/universal/api-client/src/fetch/createProtectedFetchMethod.test.ts +++ b/universal/api-client/src/fetch/createProtectedFetchMethod.test.ts @@ -24,7 +24,6 @@ describe('createProtectedFetchMethod', () => { const options: ProtectedFetchMethodOptions = { intervalTimeout: 100, requestTimeout: 2000, - requestName: 'TestRequest', retries: 2, } @@ -71,7 +70,7 @@ describe('createProtectedFetchMethod', () => { expect(() => createProtectedFetchMethod(options)).toThrow(abortError) expect(warnSpy).toHaveBeenCalledWith( - 'TestRequest request aborted due to network issues. This request may not be retried.', + 'The request aborted due to network issues. This request may not be retried.', ) expect(errorSpy).not.toHaveBeenCalled() }) @@ -84,7 +83,7 @@ describe('createProtectedFetchMethod', () => { expect(() => createProtectedFetchMethod(options)).toThrow(someError) expect(errorSpy).toHaveBeenCalledWith( - 'TestRequest request failed with error: [NetworkError] Something went wrong', + 'The request failed with error: [NetworkError] Something went wrong', ) expect(warnSpy).not.toHaveBeenCalled() }) diff --git a/universal/api-client/src/fetch/createProtectedFetchMethod.ts b/universal/api-client/src/fetch/createProtectedFetchMethod.ts index b0ce3e76..b807a998 100644 --- a/universal/api-client/src/fetch/createProtectedFetchMethod.ts +++ b/universal/api-client/src/fetch/createProtectedFetchMethod.ts @@ -6,12 +6,43 @@ import { } from './createTimeoutFetchMethod' import type { FetchMethod } from './Fetch' +/** + * Options for {@link createProtectedFetchMethod}, combining timeout and retry behavior. + */ export interface ProtectedFetchMethodOptions extends RetryFetchMethodOptions, - TimeoutFetchMethodOptions { - requestName?: string -} + TimeoutFetchMethodOptions {} +/** + * Creates a {@link FetchMethod} that combines timeout and retry protection. + * + * @param options - Configuration options for both timeout and retry behavior. + * @returns A {@link FetchMethod} that applies timeout and retry logic to requests. + * + * @remarks + * The resulting method first wraps the base fetch with a timeout (via + * {@link createTimeoutFetchMethod}), then applies retry behavior (via + * {@link createRetryFetchMethod}). + * + * If an error is thrown during configuration or request execution, it is logged + * using {@link logger}. + * + * @throws {@link Error} + * Rethrows the original error after logging, including abort errors. + * + * @example + * ```ts + * const fetchProtected = createProtectedFetchMethod({ + * apiName: 'Optimization', + * requestTimeout: 4000, + * retries: 2, + * }) + * + * const response = await fetchProtected('https://example.com/experiences', { + * method: 'GET', + * }) + * ``` + */ export function createProtectedFetchMethod(options: ProtectedFetchMethodOptions): FetchMethod { try { const timeoutFetchMethod = createTimeoutFetchMethod(options) @@ -19,15 +50,11 @@ export function createProtectedFetchMethod(options: ProtectedFetchMethodOptions) return retryFetchMethod } catch (error) { - const { requestName } = options - if (error instanceof Error) { if (error.name === 'AbortError') { - logger.warn( - `${requestName} request aborted due to network issues. This request may not be retried.`, - ) + logger.warn(`The request aborted due to network issues. This request may not be retried.`) } else { - logger.error(`${requestName} request failed with error: [${error.name}] ${error.message}`) + logger.error(`The request failed with error: [${error.name}] ${error.message}`) } } throw error diff --git a/universal/api-client/src/fetch/createRetryFetchMethod.ts b/universal/api-client/src/fetch/createRetryFetchMethod.ts index b73c9f8f..c135d5fd 100644 --- a/universal/api-client/src/fetch/createRetryFetchMethod.ts +++ b/universal/api-client/src/fetch/createRetryFetchMethod.ts @@ -2,14 +2,54 @@ import { logger } from 'logger' import retry from 'p-retry' import type { BaseFetchMethodOptions, FetchMethod, FetchMethodCallbackOptions } from './Fetch' +/** + * Default interval (in milliseconds) between retry attempts. + * + * @internal + */ const DEFAULT_INTERVAL_TIMEOUT = 0 + +/** + * Default number of retry attempts. + * + * @internal + */ const DEFAULT_RETRY_COUNT = 1 + +/** + * HTTP status code that triggers a retry. + * + * @internal + * + * @remarks + * This value is currently fixed to `503 Service Unavailable`. + */ const RETRY_RESPONSE_STATUS = 503 + +/** + * Default HTTP status code used for {@link HttpError}. + * + * @internal + */ const HTTP_ERROR_RESPONSE_STATUS = 500 +/** + * Error type representing HTTP failures with an associated status code. + * + * @internal + */ class HttpError extends Error { + /** + * The HTTP status code associated with the error. + */ public status: number + /** + * Creates a new {@link HttpError}. + * + * @param message - Description of the error. + * @param status - HTTP status code associated with the error. + */ constructor(message: string, status: number = HTTP_ERROR_RESPONSE_STATUS) { super(message) Object.setPrototypeOf(this, HttpError.prototype) @@ -17,18 +57,68 @@ class HttpError extends Error { } } +/** + * Configuration options for {@link createRetryFetchMethod}. + */ export interface RetryFetchMethodOptions extends BaseFetchMethodOptions { + /** + * Delay (in milliseconds) between retry attempts. + * + * @remarks + * Defaults to {@link DEFAULT_INTERVAL_TIMEOUT}. + */ intervalTimeout?: number + + /** + * Callback invoked whenever a retry attempt fails. + * + * @param options - Information about the failed attempt. + * + * @remarks + * This callback is invoked with additional metadata such as the attempt + * number and the number of retries left. + */ onFailedAttempt?: (options: FetchMethodCallbackOptions) => void + + /** + * Maximum number of retry attempts. + * + * @remarks + * Defaults to {@link DEFAULT_RETRY_COUNT}. + */ retries?: number } +/** + * Internal configuration passed to the retry callback. + * + * @internal + */ interface RetryFetchCallbackOptions extends RetryFetchMethodOptions { + /** + * Abort controller used to cancel the underlying fetch requests. + */ controller: AbortController + + /** + * Initialization options passed to the `fetch` implementation. + */ init: RequestInit + + /** + * Request URL. + */ url: string | URL } +/** + * Creates a callback function used by `p-retry` to perform a fetch with retry logic. + * + * @param options - Internal options controlling the retry behavior. + * @returns A function that, when invoked, performs the fetch and applies retry rules. + * + * @internal + */ function createRetryFetchCallback({ apiName = 'Optimization', controller, @@ -78,6 +168,34 @@ function createRetryFetchCallback({ } } +/** + * Creates a {@link FetchMethod} that retries failed requests according to the + * provided configuration. + * + * @param options - Configuration options that control retry behavior. + * @returns A {@link FetchMethod} that automatically retries qualifying failures. + * + * @remarks + * This wrapper integrates with `p-retry` and uses an {@link AbortController} + * to cancel pending requests when a non-retriable error occurs. + * + * @throws {@link Error} + * Thrown when the request cannot be retried and no successful response is obtained. + * + * @example + * ```ts + * const fetchWithRetry = createRetryFetchMethod({ + * apiName: 'Optimization', + * retries: 3, + * intervalTimeout: 200, + * onFailedAttempt: ({ attemptNumber, retriesLeft }) => { + * console.warn(`Attempt ${attemptNumber} failed. Retries left: ${retriesLeft}`) + * }, + * }) + * + * const response = await fetchWithRetry('https://example.com', { method: 'GET' }) + * ``` + */ export function createRetryFetchMethod({ apiName = 'Optimization', fetchMethod = fetch, diff --git a/universal/api-client/src/fetch/createTimeoutFetchMethod.ts b/universal/api-client/src/fetch/createTimeoutFetchMethod.ts index be6f8038..a1951d80 100644 --- a/universal/api-client/src/fetch/createTimeoutFetchMethod.ts +++ b/universal/api-client/src/fetch/createTimeoutFetchMethod.ts @@ -1,13 +1,63 @@ import { logger } from 'logger' import type { BaseFetchMethodOptions, FetchMethod, FetchMethodCallbackOptions } from './Fetch' +/** + * Default timeout (in milliseconds) for outgoing requests. + * + * @internal + */ +const DEFAULT_REQUEST_TIMEOUT = 3000 + +/** + * Configuration options for {@link createTimeoutFetchMethod}. + */ export interface TimeoutFetchMethodOptions extends BaseFetchMethodOptions { + /** + * Callback invoked when a request exceeds the configured timeout. + * + * @param options - Information about the timed-out request. + * + * @remarks + * If this callback is not provided, a default error is logged. + * + * @see {@link FetchMethodCallbackOptions} + */ onRequestTimeout?: (options: FetchMethodCallbackOptions) => void + + /** + * Maximum time (in milliseconds) to wait for a response before aborting the request. + * + * @remarks + * Defaults to {@link DEFAULT_REQUEST_TIMEOUT}. + */ requestTimeout?: number } -const DEFAULT_REQUEST_TIMEOUT = 3000 - +/** + * Creates a {@link FetchMethod} that aborts requests after a configurable timeout. + * + * @param options - Configuration options controlling timeout behavior. + * @returns A {@link FetchMethod} that enforces a timeout for each request. + * + * @remarks + * When a timeout occurs, the request is aborted using an {@link AbortController}. + * If `onRequestTimeout` is not provided, an error is logged by the {@link logger}. + * + * @example + * ```ts + * const fetchWithTimeout = createTimeoutFetchMethod({ + * apiName: 'Optimization', + * requestTimeout: 5000, + * onRequestTimeout: ({ apiName }) => { + * console.warn(`${apiName} request timed out`) + * }, + * }) + * + * const response = await fetchWithTimeout('https://example.com', { method: 'GET' }) + * ``` + * + * @see {@link TimeoutFetchMethodOptions} + */ export function createTimeoutFetchMethod({ apiName = 'Optimization', fetchMethod = fetch, diff --git a/universal/api-client/src/insights/InsightsApiClient.ts b/universal/api-client/src/insights/InsightsApiClient.ts index 8ea2cde5..f00b4ccb 100644 --- a/universal/api-client/src/insights/InsightsApiClient.ts +++ b/universal/api-client/src/insights/InsightsApiClient.ts @@ -2,21 +2,99 @@ import { BatchInsightsEventArray } from '@contentful/optimization-api-schemas' import { logger } from 'logger' import ApiClientBase, { type ApiConfig } from '../ApiClientBase' +/** + * Default base URL for the Insights ingest API. + * + * @public + */ +export const INSIGHTS_BASE_URL = 'https://ingest.insights.ninetailed.co/' + +/** + * Options that control how Insights events are sent. + * + * @public + */ interface RequestOptions { /** - * `beaconHandler` allows the usage of the Beacon API, or any similar request handler, instead of direct posting of data via `fetch` in the SDK. + * Handler used to enqueue events via the Beacon API or a similar mechanism. + * + * @param url - Target URL for the batched events. + * @param data - Array of batched insights events to be sent. + * @returns `true` if the events were successfully queued, `false` otherwise. + * + * @remarks + * When provided, this handler is preferred over direct `fetch` calls. If it + * returns `false`, the client falls back to emitting events immediately via + * `fetch`. */ beaconHandler?: (url: string | URL, data: BatchInsightsEventArray) => boolean } +/** + * Configuration for {@link InsightsApiClient}. + * + * @public + */ export interface InsightsApiClientConfig extends ApiConfig, RequestOptions {} -export const INSIGHTS_BASE_URL = 'https://ingest.insights.ninetailed.co/' - +/** + * Client for sending analytics and insights events to the Ninetailed Insights API. + * + * @public + * + * @remarks + * This client is optimized for sending batched events, optionally using a + * custom beacon-like handler when available. + * + * @example + * ```ts + * const insightsClient = new InsightsApiClient({ + * clientId: 'org-id', + * environment: 'main', + * preview: false, + * }) + * + * await insightsClient.sendBatchEvents([ + * { + * profile: { id: 'profile-123', ... }, + * events: [ + * { + * type: 'track', + * event: 'button_clicked', + * properties: { id: 'primary-cta' }, + * }, + * ], + * } + * ]) + * ``` + */ export default class InsightsApiClient extends ApiClientBase { + /** + * Base URL used for Insights API requests. + */ protected readonly baseUrl: string + + /** + * Optional handler used to enqueue events via the Beacon API or a similar mechanism. + */ private readonly beaconHandler: RequestOptions['beaconHandler'] + /** + * Creates a new {@link InsightsApiClient} instance. + * + * @param config - Configuration for the Insights API client. + * + * @example + * ```ts + * const client = new InsightsApiClient({ + * clientId: 'org-id', + * environment: 'main', + * beaconHandler: (url, data) => { + * return navigator.sendBeacon(url.toString(), JSON.stringify(data)) + * }, + * }) + * ``` + */ constructor(config: InsightsApiClientConfig) { super('Insights', config) @@ -26,6 +104,40 @@ export default class InsightsApiClient extends ApiClientBase { this.beaconHandler = beaconHandler } + /** + * Sends batches of insights events to the Ninetailed Insights API. + * + * @param batches - Array of event batches to send. + * @param options - Optional request options, including a per-call `beaconHandler`. + * @returns A promise that resolves when the events have been sent or queued. + * + * @remarks + * If a `beaconHandler` is provided (either in the method call or in the + * client configuration) it will be invoked first. When the handler returns + * `true`, the events are considered successfully queued and no network + * request is made by this method. + * + * If the handler is missing or returns `false`, the events are emitted + * immediately via `fetch`. + * + * @throws {@link Error} + * Rethrows any error originating from the underlying `fetch` call. + * + * @example + * ```ts + * await insightsClient.sendBatchEvents(batches) + * ``` + * + * @example + * ```ts + * // Override beaconHandler for a single call + * await insightsClient.sendBatchEvents(batches, { + * beaconHandler: (url, data) => { + * return navigator.sendBeacon(url.toString(), JSON.stringify(data)) + * }, + * }) + * ``` + */ public async sendBatchEvents( batches: BatchInsightsEventArray, options: RequestOptions = {}, diff --git a/universal/api-schemas/README.md b/universal/api-schemas/README.md index e69de29b..8e952b27 100644 --- a/universal/api-schemas/README.md +++ b/universal/api-schemas/README.md @@ -0,0 +1,123 @@ +

+ + Contentful Logo + +

+ +

Contentful Personalization & Analytics

+ +

API Schema Library

+ +
+ +[Readme](./README.md) · [Reference](https://contentful.github.io/optimization) · +[Contributing](/CONTRIBUTING.md) + +
+ +The Contentful Optimization API Schema Library is a collection of Zod Mini schemas and their +inferred TypeScript types. These schemas help provide run-time validation when working with requests +and responses for the APIs referenced within Optimization SDKs. + +
+ Table of Contents + + +- [Getting Started](#getting-started) +- [Contentful CDA Schemas](#contentful-cda-schemas) + - [Essential Schemas](#essential-schemas) + - [Essential Functions](#essential-functions) +- [Experience API Schemas](#experience-api-schemas) + - [Essential Experience API Request Schemas](#essential-experience-api-request-schemas) + - [Essential Experience API Response Schemas](#essential-experience-api-response-schemas) +- [Insights API Schemas](#insights-api-schemas) + - [Essential Insights API Response Schemas](#essential-insights-api-response-schemas) + + +
+ +## Getting Started + +Install using an NPM-compatible package manager, pnpm for example: + +```sh +pnpm install @contentful/optimization-api-schemas +``` + +Consult [Zod's documentation](https://zod.dev/basics) for more information on working with +[Zod Mini](https://zod.dev/packages/mini) schemas. + +## Contentful CDA Schemas + +These schemas assist in determining whether Contentful content entries provided by the CDA and its +SDK are valid for personalization. These schemas do not encapsulate all features and functionality +specified in the CDA SDK's exported TypeScript type system, but strive to remain compatible enough +for the purposes of personalization. + +### Essential Schemas + +- `CtflEntry`: Zod schema describing a generic Contentful entry; the `fields` member is loosely + typed as any valid JSON +- `PersonalizedEntry`: Zod schema describing a `CtflEntry` that has associated personalization + entries +- `PersonalizationEntry`: Zod schema describing a personalization entry, which is associated with a + `PersonalizedEntry` via its `fields.nt_experiences` property +- `PersonalizationConfig`: Zod schema describing the configuration of a `PersonalizationEntry` via + its `fields.nt_config` property + +### Essential Functions + +- `isEntry`: Type guard that + checks whether the given value is a Contentful Entry, passing through the specified skeleton, + chain modifiers, and locale +- `isPersonalizedEntry`: Type guard for `PersonalizedEntry` +- `isPersonalizationEntry`: Type guard for `PersonalizationEntry` + +## Experience API Schemas + +These schemas help validate at run-time that both the request and response data for Experience API +requests conform to current API specifications. + +### Essential Experience API Request Schemas + +- `ExperienceRequestData`: Zod schema describing the data payload for an experience request +- `ExperienceEvent`: Zod schema union of supported experience/personalization events +- `BatchExperienceEvent`: Zod schema describing each valid experience/personalization event within a + batch; Similar to `ExperienceEvent`, but with an additional `anonymousId` member on each event + schema + +Experience/personalization event schemas: + +- `AliasEvent`: Zod schema describing an `alias` event +- `ComponentViewEvent`: Zod schema describing a `component` view event (may be a Contentful entry or + a Custom Flag) +- `IdentifyEvent`: Zod schema describing an `identify` event +- `PageViewEvent`: Zod schema describing a `page` view event +- `ScreenViewEvent`: Zod schema describing a `screen` view event +- `TrackEvent`: Zod schema describing a custom `track` event + +### Essential Experience API Response Schemas + +- `ExperienceResponse`: Zod schema describing a full Experience API response; includes a `data` + object with `changes`, `experiences`, and `profile` properties +- `BatchExperienceResponse`: Zod schema describing a batch experience response from the Experience + API; includes a `profiles` collection +- `Change`: Union of supported change types, which currently only includes `VariableChange`; this + change type is used for Custom Flags +- `SelectedPersonalization`: Zod schema describing a selected personalization outcome for a user +- `Profile`: Zod schema describing a full user profile as received from the Experience API + +## Insights API Schemas + +Insights API endpoints currently do not return response data. + +### Essential Insights API Response Schemas + +- `InsightsEvent`: Zod schema union of supported insights/analytics events +- `BatchInsightsEvent`: Zod schema describing a batched Insights event payload; expects a `profile` + property alongside a collection of `events` + +Insights/analytics event schemas: + +- `ComponentViewEvent`: Zod schema describing a `component` view event (may be a Contentful entry or + a Custom Flag) diff --git a/universal/api-schemas/package.json b/universal/api-schemas/package.json index 6616acc5..9e6c4fd5 100644 --- a/universal/api-schemas/package.json +++ b/universal/api-schemas/package.json @@ -1,6 +1,6 @@ { "name": "@contentful/optimization-api-schemas", - "version": "1.0.0", + "version": "0.0.0", "license": "MIT", "type": "commonjs", "main": "./dist/index.cjs", diff --git a/universal/api-schemas/src/contentful/AudienceEntry.ts b/universal/api-schemas/src/contentful/AudienceEntry.ts index 6182e5a5..578ea5f8 100644 --- a/universal/api-schemas/src/contentful/AudienceEntry.ts +++ b/universal/api-schemas/src/contentful/AudienceEntry.ts @@ -1,25 +1,54 @@ import * as z from 'zod/mini' import { CtflEntry, EntryFields } from './CtflEntry' +/** + * Zod schema describing the fields of an Audience entry. + * + * @remarks + * Extends the base {@link EntryFields} with audience-specific properties. + */ export const AudienceEntryFields = z.extend(EntryFields, { /** - * The internal id of the audience (Short Text) + * The internal id of the audience (Short Text). + * + * @remarks + * This usually corresponds to a stable identifier used by the personalization system. */ nt_audience_id: z.string(), /** - * The name of the audience (Short Text) + * The name of the audience (Short Text). + * + * @remarks + * Optional field used for display purposes in tools and UI. */ nt_name: z.optional(z.string()), /** - * The description of the audience (Short Text) + * The description of the audience (Short Text). + * + * @remarks + * Optional field intended for internal documentation and operator context. */ nt_description: z.optional(z.string()), }) + +/** + * TypeScript type inferred from {@link AudienceEntryFields}. + */ export type AudienceEntryFields = z.infer +/** + * Zod schema for a Contentful Audience entry, including system metadata. + * + * @remarks + * Extends the generic {@link CtflEntry} with {@link AudienceEntryFields} as the `fields` payload. + */ export const AudienceEntry = z.extend(CtflEntry, { fields: AudienceEntryFields, }) + +/** + * TypeScript type inferred from {@link AudienceEntry}. + */ export type AudienceEntry = z.infer diff --git a/universal/api-schemas/src/contentful/CtflEntry.ts b/universal/api-schemas/src/contentful/CtflEntry.ts index 2ecd6df7..63e2d63d 100644 --- a/universal/api-schemas/src/contentful/CtflEntry.ts +++ b/universal/api-schemas/src/contentful/CtflEntry.ts @@ -1,9 +1,27 @@ import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful' import * as z from 'zod/mini' +/** + * Base Zod schema for entry fields. + * + * @remarks + * This is modeled as a catch-all map from string keys to JSON-compatible values. + * The strong typing ot consumer-specified Contentful Entry fields is not + * validated by these schemas. + */ export const EntryFields = z.catchall(z.object({}), z.json()) + +/** + * TypeScript type inferred from {@link EntryFields}. + */ export type EntryFields = z.infer +/** + * Zod schema representing a generic Contentful Link object. + * + * @remarks + * This is used for references to other Contentful resources where `linkType` is not constrained. + */ export const Link = z.object({ sys: z.object({ type: z.literal('Link'), @@ -11,8 +29,15 @@ export const Link = z.object({ id: z.string(), }), }) + +/** + * TypeScript type inferred from {@link Link}. + */ export type Link = z.infer +/** + * Zod schema representing a Contentful ContentType link. + */ export const ContentTypeLink = z.object({ sys: z.object({ type: z.literal('Link'), @@ -20,8 +45,15 @@ export const ContentTypeLink = z.object({ id: z.string(), }), }) + +/** + * TypeScript type inferred from {@link ContentTypeLink}. + */ export type ContentTypeLink = z.infer +/** + * Zod schema representing a Contentful Environment link. + */ export const EnvironmentLink = z.object({ sys: z.object({ type: z.literal('Link'), @@ -29,8 +61,15 @@ export const EnvironmentLink = z.object({ id: z.string(), }), }) + +/** + * TypeScript type inferred from {@link EnvironmentLink}. + */ export type EnvironmentLink = z.infer +/** + * Zod schema representing a Contentful Space link. + */ export const SpaceLink = z.object({ sys: z.object({ type: z.literal('Link'), @@ -38,8 +77,15 @@ export const SpaceLink = z.object({ id: z.string(), }), }) + +/** + * TypeScript type inferred from {@link SpaceLink}. + */ export type SpaceLink = z.infer +/** + * Zod schema representing a Contentful Tag link. + */ export const TagLink = z.object({ sys: z.object({ type: z.literal('Link'), @@ -47,8 +93,19 @@ export const TagLink = z.object({ id: z.string(), }), }) + +/** + * TypeScript type inferred from {@link TagLink}. + */ export type TagLink = z.infer +/** + * Zod schema describing the `sys` block for a Contentful entry. + * + * @remarks + * This mirrors the structure of `Entry['sys']` from the Contentful SDK with + * a subset of fields used by this library. + */ export const EntrySys = z.object({ type: z.literal('Entry'), contentType: ContentTypeLink, @@ -61,20 +118,59 @@ export const EntrySys = z.object({ space: SpaceLink, environment: EnvironmentLink, }) + +/** + * TypeScript type inferred from {@link EntrySys}. + */ export type EntrySys = z.infer +/** + * Zod schema describing a generic Contentful entry. + * + * @remarks + * This model is intentionally loose: `fields` is any JSON-compliant object and + * `metadata` is modeled as a catch-all object that must contain an array of + * {@link TagLink} tags. + */ export const CtflEntry = z.object({ - fields: z.unknown(), + /** + * The entry fields payload. + */ + fields: EntryFields, + + /** + * Contentful metadata, including tags. + */ metadata: z.catchall( z.object({ tags: z.array(TagLink), }), z.json(), ), + + /** + * System-managed properties describing the entry. + */ sys: EntrySys, }) + +/** + * TypeScript type inferred from {@link CtflEntry}. + */ export type CtflEntry = z.infer +/** + * Type guard that checks whether the given value is a Contentful {@link Entry}, + * passing through the specified skeleton, chain modifiers, and locale. + * + * @typeParam S - The entry skeleton type. + * @typeParam M - The chain modifiers type. Defaults to {@link ChainModifiers}. + * @typeParam L - The locale code type. Defaults to {@link LocaleCode}. + * + * @param entry - The value to test. + * @returns `true` if the object conforms to {@link CtflEntry} and can be treated + * as a typed {@link Entry}, otherwise `false`. + */ export function isEntry< S extends EntrySkeletonType, M extends ChainModifiers = ChainModifiers, diff --git a/universal/api-schemas/src/contentful/MergeTagEntry.ts b/universal/api-schemas/src/contentful/MergeTagEntry.ts index b732cbf2..339b54f3 100644 --- a/universal/api-schemas/src/contentful/MergeTagEntry.ts +++ b/universal/api-schemas/src/contentful/MergeTagEntry.ts @@ -1,12 +1,34 @@ import * as z from 'zod/mini' import { CtflEntry, EntrySys } from './CtflEntry' +/** + * Zod schema for a Merge Tag Contentful entry. + * + * @remarks + * Extends {@link CtflEntry} with merge-tag-specific fields and constrains the + * `contentType` to the `nt_mergetag` content type. + */ export const MergeTagEntry = z.extend(CtflEntry, { fields: z.object({ + /** + * Human-readable name of the merge tag. + */ nt_name: z.string(), + + /** + * Fallback value to use when the merge tag cannot be resolved. + */ nt_fallback: z.optional(z.string()), + + /** + * Internal identifier of the merge tag. + */ nt_mergetag_id: z.string(), }), + + /** + * System fields extended to constrain the content type to `nt_mergetag`. + */ sys: z.extend(EntrySys, { contentType: z.object({ sys: z.object({ @@ -17,4 +39,8 @@ export const MergeTagEntry = z.extend(CtflEntry, { }), }), }) + +/** + * TypeScript type inferred from {@link MergeTagEntry}. + */ export type MergeTagEntry = z.infer diff --git a/universal/api-schemas/src/contentful/PersonalizationConfig.ts b/universal/api-schemas/src/contentful/PersonalizationConfig.ts index 8d55029c..890e7245 100644 --- a/universal/api-schemas/src/contentful/PersonalizationConfig.ts +++ b/universal/api-schemas/src/contentful/PersonalizationConfig.ts @@ -1,61 +1,201 @@ import * as z from 'zod/mini' +/** + * Zod schema describing a single entry replacement variant. + * + * @remarks + * Each variant is identified by an `id` and may be marked as `hidden`. + */ export const EntryReplacementVariant = z.object({ + /** + * Unique identifier for the variant. + */ id: z.string(), + + /** + * Indicates whether this variant is hidden from allocation/traffic. + * + * @defaultValue false + */ hidden: z.prefault(z.boolean(), false), }) + +/** + * TypeScript type inferred from {@link EntryReplacementVariant}. + */ export type EntryReplacementVariant = z.infer +/** + * Type guard for {@link EntryReplacementVariant}. + * + * @param variant - Value to test. + * @returns `true` if `variant` conforms to {@link EntryReplacementVariant}, otherwise `false`. + */ export function isEntryReplacementVariant(variant: unknown): variant is EntryReplacementVariant { return EntryReplacementVariant.safeParse(variant).success } +/** + * Zod schema describing an entry replacement personalization component. + * + * @remarks + * This component replaces a baseline entry with one of several variants. + */ export const EntryReplacementComponent = z.object({ + /** + * Discriminator for the component type. + * + * @remarks + * May be omitted, in which case the component is treated as an EntryReplacement. + */ type: z.optional(z.literal('EntryReplacement')), + + /** + * Baseline variant used when no targeting or allocation selects another variant. + */ baseline: EntryReplacementVariant, + + /** + * Additional variants that may be served. + */ variants: z.array(EntryReplacementVariant), }) + +/** + * TypeScript type inferred from {@link EntryReplacementComponent}. + */ export type EntryReplacementComponent = z.infer +/** + * Type guard for {@link EntryReplacementComponent}. + * + * @param component - Personalization component to test. + * @returns `true` if the component is an EntryReplacement component, otherwise `false`. + */ export function isEntryReplacementComponent( component: PersonalizationComponent, ): component is EntryReplacementComponent { return component.type === 'EntryReplacement' || component.type === undefined } +/** + * Zod schema describing a variant for inline variables. + * + * @remarks + * The value may be a primitive or a JSON object. + */ export const InlineVariableVariant = z.object({ + /** + * Variant value for the inline variable. + */ value: z.union([z.string(), z.boolean(), z.null(), z.number(), z.record(z.string(), z.json())]), }) +/** + * Enumeration of supported inline variable value types. + */ export const InlineVariableComponentValueType = z.enum(['Boolean', 'Number', 'Object', 'String']) +/** + * Zod schema describing an inline variable personalization component. + * + * @remarks + * Used to vary scalar or object values in templates. + */ export const InlineVariableComponent = z.object({ + /** + * Discriminator for the inline variable component. + */ type: z.literal('InlineVariable'), + + /** + * Key under which this variable is exposed to the template. + */ key: z.string(), + + /** + * Describes the runtime type of the values for this variable. + */ valueType: InlineVariableComponentValueType, + + /** + * Baseline value used when no targeting or allocation selects another variant. + */ baseline: InlineVariableVariant, + + /** + * Additional variable variants for experimentation or personalization. + */ variants: z.array(InlineVariableVariant), }) + +/** + * TypeScript type inferred from {@link InlineVariableComponent}. + */ export type InlineVariableComponent = z.infer +/** + * Type guard for {@link InlineVariableComponent}. + * + * @param component - Personalization component to test. + * @returns `true` if the component is an InlineVariable component, otherwise `false`. + */ export function isInlineVariableComponent( component: PersonalizationComponent, ): component is InlineVariableComponent { return component.type === 'InlineVariable' } +/** + * Discriminated union of all supported personalization components. + */ export const PersonalizationComponent = z.discriminatedUnion('type', [ EntryReplacementComponent, InlineVariableComponent, ]) + +/** + * TypeScript type inferred from {@link PersonalizationComponent}. + */ export type PersonalizationComponent = z.infer +/** + * Zod schema representing an array of {@link PersonalizationComponent} items. + */ export const PersonalizationComponentArray = z.array(PersonalizationComponent) + +/** + * TypeScript type inferred from {@link PersonalizationComponentArray}. + */ export type PersonalizationComponentArray = z.infer +/** + * Zod schema describing the full configuration for a personalization. + * + * @remarks + * Provides distribution, traffic allocation, component definitions, and sticky behavior. + */ export const PersonalizationConfig = z.object({ + /** + * Variant distribution used for traffic allocation. + * + * @defaultValue [0.5, 0.5] + */ distribution: z.optional(z.prefault(z.array(z.number()), [0.5, 0.5])), + + /** + * Percentage of total traffic that should enter the personalization. + * + * @defaultValue 0 + */ traffic: z.optional(z.prefault(z.number(), 0)), + + /** + * Personalization components that define how content is varied. + * + * @defaultValue + * A single {@link EntryReplacementComponent} with an empty `baseline` and `variants` ID. + */ components: z.optional( z.prefault(PersonalizationComponentArray, [ { @@ -65,6 +205,16 @@ export const PersonalizationConfig = z.object({ }, ]), ), + + /** + * Controls whether the assignment should be sticky for a given user. + * + * @defaultValue false + */ sticky: z.optional(z.prefault(z.boolean(), false)), }) + +/** + * TypeScript type inferred from {@link PersonalizationConfig}. + */ export type PersonalizationConfig = z.infer diff --git a/universal/api-schemas/src/contentful/PersonalizationEntry.ts b/universal/api-schemas/src/contentful/PersonalizationEntry.ts index 76c7fd0c..bf114d30 100644 --- a/universal/api-schemas/src/contentful/PersonalizationEntry.ts +++ b/universal/api-schemas/src/contentful/PersonalizationEntry.ts @@ -4,30 +4,55 @@ import { AudienceEntry } from './AudienceEntry' import { CtflEntry, EntryFields, Link } from './CtflEntry' import { PersonalizationConfig } from './PersonalizationConfig' +/** + * Union of supported personalization types. + * + * @remarks + * `nt_experiment` represents experiments (A/B tests), and `nt_personalization` + * represents always-on personalized experiences. + */ export const PersonalizationType = z.union([ z.literal('nt_experiment'), z.literal('nt_personalization'), ]) + +/** + * TypeScript type inferred from {@link PersonalizationType}. + */ export type PersonalizationType = z.infer +/** + * Zod schema describing the fields of a Personalization entry. + * + * @remarks + * Extends the generic {@link EntryFields} with personalization-specific + * properties such as name, description, type, config, audience, and variants. + */ export const PersonalizationFields = z.extend(EntryFields, { /** - * The name of the experience (Short Text) + * The name of the personalization (Short Text). */ nt_name: z.string(), /** - * The description of the experience (Short Text) + * The description of the personalization (Short Text). + * + * @remarks + * Optional, may be `null` if no description is provided. */ nt_description: z.optional(z.nullable(z.string())), /** - * The type if the experience (nt_experiment | nt_personalization) + * The type of the personalization (`nt_experiment` | `nt_personalization`). */ nt_type: PersonalizationType, /** - * The config of the experience (JSON) + * The configuration of a {@link PersonalizationEntry } (JSON). + * + * @remarks + * Accepts `null` or an explicit {@link PersonalizationConfig} and converts + * falsy/undefined values into a default configuration. */ nt_config: z.pipe( z.optional(z.prefault(z.nullable(PersonalizationConfig), null)), @@ -43,25 +68,60 @@ export const PersonalizationFields = z.extend(EntryFields, { ), /** - * The audience of the experience (Audience) + * The audience of the personalization (Audience). + * + * @remarks + * Optional and nullable; when omitted or `null`, the personalization may apply + * to all users. */ nt_audience: z.optional(z.nullable(AudienceEntry)), /** - * All used variants of the experience (Contentful references to other Content Types) + * All used variants of the personalization (Contentful references to other Content Types). + * + * @remarks + * Modeled as an array of untyped Contentful entries and defaults to an empty + * array when omitted. */ nt_variants: z.optional(z.prefault(z.array(z.custom()), [])), }) + +/** + * TypeScript type inferred from {@link PersonalizationFields}. + */ export type PersonalizationFields = z.infer +/** + * Zod schema describing a Personalization entry, which is associated with a {@link PersonalizedEntry } via its `fields.nt_experiences`. + */ export const PersonalizationEntry = z.extend(CtflEntry, { fields: PersonalizationFields, }) + +/** + * TypeScript type inferred from {@link PersonalizationEntry}. + */ export type PersonalizationEntry = z.infer +/** + * Type guard for {@link PersonalizationEntry}. + * + * @param entry - Contentful entry or link to test. + * @returns `true` if the value conforms to {@link PersonalizationEntry}, otherwise `false`. + */ export function isPersonalizationEntry(entry: CtflEntry | Link): entry is PersonalizationEntry { return PersonalizationEntry.safeParse(entry).success } +/** + * Zod schema describing an array of personalization entries or links. + * + * @remarks + * Each element may be a {@link Link} or a fully resolved {@link PersonalizationEntry}. + */ export const PersonalizationEntryArray = z.array(z.union([Link, PersonalizationEntry])) + +/** + * TypeScript type inferred from {@link PersonalizationEntryArray}. + */ export type PersonalizationEntryArray = z.infer diff --git a/universal/api-schemas/src/contentful/PersonalizedEntry.ts b/universal/api-schemas/src/contentful/PersonalizedEntry.ts index d2577878..77b17c66 100644 --- a/universal/api-schemas/src/contentful/PersonalizedEntry.ts +++ b/universal/api-schemas/src/contentful/PersonalizedEntry.ts @@ -3,13 +3,32 @@ import * as z from 'zod/mini' import { CtflEntry, EntryFields } from './CtflEntry' import { PersonalizationEntryArray } from './PersonalizationEntry' +/** + * Zod schema describing a Contentful entry that has attached personalizations. + * + * @remarks + * Extends {@link CtflEntry} and adds `nt_experiences` to the `fields` object. + */ export const PersonalizedEntry = z.extend(CtflEntry, { fields: z.extend(EntryFields, { + /** + * Personalization or experimentation experiences attached to this entry. + */ nt_experiences: PersonalizationEntryArray, }), }) + +/** + * TypeScript type inferred from {@link PersonalizedEntry}. + */ export type PersonalizedEntry = z.infer +/** + * Type guard for {@link PersonalizedEntry}. + * + * @param entry - Contentful entry to test. + * @returns `true` if the entry conforms to {@link PersonalizedEntry}, otherwise `false`. + */ export function isPersonalizedEntry(entry: Entry | undefined): entry is PersonalizedEntry { return PersonalizedEntry.safeParse(entry).success } diff --git a/universal/api-schemas/src/experience/BatchExperienceResponse.ts b/universal/api-schemas/src/experience/BatchExperienceResponse.ts index 436e13e4..c10eba69 100644 --- a/universal/api-schemas/src/experience/BatchExperienceResponse.ts +++ b/universal/api-schemas/src/experience/BatchExperienceResponse.ts @@ -2,8 +2,35 @@ import * as z from 'zod/mini' import { Profile } from './profile' import { ResponseEnvelope } from './ResponseEnvelope' -export const BatchExperienceData = z.object({ profiles: z.optional(z.array(Profile)) }) +/** + * Zod schema describing the `data` property of a batch experience response. + * + * @remarks + * A batch request may return zero or more profiles. When no profiles are + * returned, `profiles` may be omitted or an empty array. + */ +export const BatchExperienceData = z.object({ + /** + * Profiles evaluated or affected by the batch experience request. + */ + profiles: z.optional(z.array(Profile)), +}) + +/** + * TypeScript type inferred from {@link BatchExperienceData}. + */ export type BatchExperienceData = z.infer +/** + * Zod schema describing a batch experience response from the Experience API. + * + * @remarks + * Extends {@link ResponseEnvelope} with {@link BatchExperienceData} as the + * `data` payload. + */ export const BatchExperienceResponse = z.extend(ResponseEnvelope, { data: BatchExperienceData }) + +/** + * TypeScript type inferred from {@link BatchExperienceResponse}. + */ export type BatchExperienceResponse = z.infer diff --git a/universal/api-schemas/src/experience/ExperienceRequest.ts b/universal/api-schemas/src/experience/ExperienceRequest.ts index 4f9bb3a2..7244e48b 100644 --- a/universal/api-schemas/src/experience/ExperienceRequest.ts +++ b/universal/api-schemas/src/experience/ExperienceRequest.ts @@ -1,13 +1,48 @@ import * as z from 'zod/mini' import { ExperienceEventArray } from './event' +/** + * Zod schema describing optional configuration for an experience request. + * + * @remarks + * These options can be used to enable or filter specific features when + * evaluating experiences. + */ export const ExperienceRequestOptions = z.object({ + /** + * Features or capabilities to enable for this request. + */ features: z.optional(z.array(z.string())), }) + +/** + * TypeScript type inferred from {@link ExperienceRequestOptions}. + */ export type ExperienceRequestOptions = z.infer +/** + * Zod schema describing the data payload for an experience request. + * + * @remarks + * Contains the list of events to be evaluated plus optional request + * configuration. + */ export const ExperienceRequestData = z.object({ + /** + * Experience events that should be evaluated by the Experience API. + * + * @remarks + * Must contain at least one event. + */ events: ExperienceEventArray.check(z.minLength(1)), + + /** + * Optional configuration for this experience request. + */ options: z.optional(ExperienceRequestOptions), }) + +/** + * TypeScript type inferred from {@link ExperienceRequestData}. + */ export type ExperienceRequestData = z.infer diff --git a/universal/api-schemas/src/experience/ExperienceResponse.ts b/universal/api-schemas/src/experience/ExperienceResponse.ts index 62cc1aa4..413c8807 100644 --- a/universal/api-schemas/src/experience/ExperienceResponse.ts +++ b/universal/api-schemas/src/experience/ExperienceResponse.ts @@ -4,17 +4,62 @@ import { SelectedPersonalizationArray } from './personalization' import { Profile } from './profile' import { ResponseEnvelope } from './ResponseEnvelope' +/** + * Zod schema describing the `data` payload of a standard Experience API response. + * + * @remarks + * Contains the evaluated profile, selected personalizations, and computed + * changes that should be applied on the client. + */ export const ExperienceData = z.object({ + /** + * Profile associated with the evaluated events. + */ profile: Profile, + + /** + * Selected experiences and variants for the profile. + * + * @see {@link SelectedPersonalizationArray} + */ experiences: SelectedPersonalizationArray, + + /** + * Currently used for Custom Flags. + * + * @see {@link ChangeArray} + */ changes: ChangeArray, }) + +/** + * TypeScript type inferred from {@link ExperienceData}. + */ export type ExperienceData = z.infer +/** + * Zod schema describing a full Experience API response. + * + * @remarks + * Extends {@link ResponseEnvelope} with {@link ExperienceData} as the `data` payload. + */ export const ExperienceResponse = z.extend(ResponseEnvelope, { data: ExperienceData }) + +/** + * TypeScript type inferred from {@link ExperienceResponse}. + */ export type ExperienceResponse = z.infer -/** This type is specifically for compatibility outside the API adapter */ +/** + * Optimization data shape used for compatibility outside the API adapter. + * + * @remarks + * This type mirrors {@link ExperienceData} but replaces the `experiences` + * field with `personalizations` while preserving the rest of the structure. + */ export type OptimizationData = Omit & { + /** + * Selected personalizations for the profile. + */ personalizations: SelectedPersonalizationArray } diff --git a/universal/api-schemas/src/experience/ResponseEnvelope.ts b/universal/api-schemas/src/experience/ResponseEnvelope.ts index 4cb781e2..606d1ec3 100644 --- a/universal/api-schemas/src/experience/ResponseEnvelope.ts +++ b/universal/api-schemas/src/experience/ResponseEnvelope.ts @@ -1,8 +1,38 @@ import * as z from 'zod/mini' +/** + * Zod schema describing the common envelope structure of responses + * from the Experience API. + * + * @remarks + * Concrete responses extend this schema and refine the `data` property + * to a more specific shape. + */ export const ResponseEnvelope = z.object({ + /** + * Response payload. + * + * @remarks + * The base schema uses an empty object; specific responses extend this + * with more detailed structures. + */ data: z.object(), + + /** + * Human-readable message accompanying the response. + */ message: z.string(), + + /** + * Indicates whether an error occurred. + * + * @remarks + * May be `null` when the error state is unknown or not applicable. + */ error: z.nullable(z.boolean()), }) + +/** + * TypeScript type inferred from {@link ResponseEnvelope}. + */ export type ResponseEnvelope = z.infer diff --git a/universal/api-schemas/src/experience/change/Change.ts b/universal/api-schemas/src/experience/change/Change.ts index 5bfcc0ea..b38e9519 100644 --- a/universal/api-schemas/src/experience/change/Change.ts +++ b/universal/api-schemas/src/experience/change/Change.ts @@ -1,16 +1,59 @@ import * as z from 'zod/mini' +/** + * Enumeration of supported change types. + * + * @remarks + * Currently only the `'Variable'` change type is supported, but the union + * in {@link ChangeBase} allows for additional types to be introduced. + */ export const ChangeType = ['Variable'] as const +/** + * Zod schema describing the base shape for a change. + * + * @remarks + * This base is extended by specific change types such as {@link VariableChange}. + */ const ChangeBase = z.object({ + /** + * Key identifying the subject of the change. + */ key: z.string(), + + /** + * Discriminator for the change type. + * + * @remarks + * May be one of {@link ChangeType} or an arbitrary string for unknown types. + */ type: z.union([z.enum(ChangeType), z.string()]), + + /** + * Metadata describing the originating experience and variant index. + */ meta: z.object({ + /** + * Identifier of the personalization or experiment experience. + */ experienceId: z.string(), + + /** + * Index of the variant within the experience configuration. + * + * @remarks + * Typically corresponds to the array index in the experience's distribution. + */ variantIndex: z.number(), }), }) +/** + * Zod schema for the allowed value types of a variable change. + * + * @remarks + * Supports primitives and JSON objects keyed by strings. + */ export const VariableChangeValue = z.union([ z.string(), z.boolean(), @@ -19,23 +62,85 @@ export const VariableChangeValue = z.union([ z.record(z.string(), z.json()), ]) +/** + * Zod schema representing an unknown change type. + * + * @remarks + * This can be used to handle forward-compatible change payloads where + * the `type` is not recognized. + */ export const UnknownChange = z.extend(ChangeBase, { + /** + * Unconstrained change type string. + */ type: z.string(), + + /** + * Payload for the change value, with unknown structure. + */ value: z.unknown(), }) + +/** + * TypeScript type inferred from {@link UnknownChange}. + */ export type UnknownChange = z.infer +/** + * Zod schema representing a change whose type is `'Variable'`. + * + * @remarks + * The `value` must conform to {@link VariableChangeValue}. + */ export const VariableChange = z.extend(ChangeBase, { + /** + * Discriminator for a variable change. + */ type: z.literal('Variable'), + + /** + * New value for the variable identified by {@link ChangeBase.key}. + */ value: VariableChangeValue, }) + +/** + * TypeScript type inferred from {@link VariableChange}. + */ export type VariableChange = z.infer +/** + * JSON value type inferred from {@link z.json}. + * + * @remarks + * Represents any JSON-serializable value. + */ export type Json = z.infer + +/** + * Map of Custom Flag keys to JSON values. + */ export type Flags = Record +/** + * Union of supported change types. + * + * @remarks + * Currently only {@link VariableChange} is included. + */ export const Change = z.discriminatedUnion('type', [VariableChange]) + +/** + * TypeScript type inferred from {@link Change}. + */ export type Change = z.infer +/** + * Zod schema representing an array of {@link Change} items. + */ export const ChangeArray = z.array(Change) + +/** + * TypeScript type inferred from {@link ChangeArray}. + */ export type ChangeArray = z.infer diff --git a/universal/api-schemas/src/experience/event/AliasEvent.ts b/universal/api-schemas/src/experience/event/AliasEvent.ts index 7af2e3aa..7169fad2 100644 --- a/universal/api-schemas/src/experience/event/AliasEvent.ts +++ b/universal/api-schemas/src/experience/event/AliasEvent.ts @@ -1,7 +1,25 @@ import * as z from 'zod/mini' import { UniversalEventProperties } from './UniversalEventProperties' +/** + * Zod schema describing an `alias` event. + * + * @remarks + * Currently unused. + * + * Alias events are typically used to associate multiple identifiers + * (for example, anonymous and authenticated IDs) with the same user. + * + * Extends {@link UniversalEventProperties} with a fixed `type` field. + */ export const AliasEvent = z.extend(UniversalEventProperties, { + /** + * Discriminator indicating that this event is an alias event. + */ type: z.literal('alias'), }) + +/** + * TypeScript type inferred from {@link AliasEvent}. + */ export type AliasEvent = z.infer diff --git a/universal/api-schemas/src/experience/event/BatchExperienceEvent.ts b/universal/api-schemas/src/experience/event/BatchExperienceEvent.ts index 34187f35..530f4b35 100644 --- a/universal/api-schemas/src/experience/event/BatchExperienceEvent.ts +++ b/universal/api-schemas/src/experience/event/BatchExperienceEvent.ts @@ -7,8 +7,22 @@ import { PageViewEvent } from './PageViewEvent' import { ScreenViewEvent } from './ScreenViewEvent' import { TrackEvent } from './TrackEvent' +/** + * Partial schema capturing an anonymous identifier. + * + * @remarks + * This object is merged into each event type in a batch to associate the + * event with an `anonymousId`. + */ const Anon = { anonymousId: z.string() } +/** + * Zod schema describing each valid experience/personalization event within a batch. + * + * @remarks + * This is a discriminated union on the `type` field that supports all event + * types used in batch ingestion, each extended with an `anonymousId`. + */ export const BatchExperienceEvent = z.discriminatedUnion('type', [ z.extend(AliasEvent, Anon), z.extend(ComponentViewEvent, Anon), @@ -18,7 +32,18 @@ export const BatchExperienceEvent = z.discriminatedUnion('type', [ z.extend(ScreenViewEvent, Anon), z.extend(TrackEvent, Anon), ]) + +/** + * TypeScript type inferred from {@link BatchExperienceEvent}. + */ export type BatchExperienceEvent = z.infer +/** + * Zod schema describing an array of {@link BatchExperienceEvent} items. + */ export const BatchExperienceEventArray = z.array(BatchExperienceEvent) + +/** + * TypeScript type inferred from {@link BatchExperienceEventArray}. + */ export type BatchExperienceEventArray = z.infer diff --git a/universal/api-schemas/src/experience/event/ComponentViewEvent.ts b/universal/api-schemas/src/experience/event/ComponentViewEvent.ts index c48f2581..826fddff 100644 --- a/universal/api-schemas/src/experience/event/ComponentViewEvent.ts +++ b/universal/api-schemas/src/experience/event/ComponentViewEvent.ts @@ -1,11 +1,52 @@ import * as z from 'zod/mini' import { UniversalEventProperties } from './UniversalEventProperties' +/** + * Zod schema describing a `component` view event. + * + * @remarks + * Component view events track exposure of individual components such as + * entries or variables within a personalized experience. + * + * Extends {@link UniversalEventProperties}. + */ export const ComponentViewEvent = z.extend(UniversalEventProperties, { + /** + * Discriminator indicating that this event is a component view. + */ type: z.literal('component'), + + /** + * Type of component that was viewed. + * + * - `'Entry'` — A content entry component. + * - `'Variable'` — A variable or inline value component. + */ componentType: z.union([z.literal('Entry'), z.literal('Variable')]), + + /** + * Contentful entry ID corresponding to the component that was viewed. + */ componentId: z.string(), + + /** + * Identifier of the experience that rendered this component. + * + * @remarks + * Optional; component views may occur outside of a specific experience/personalization. + */ experienceId: z.optional(z.string()), + + /** + * Index of the variant associated with this component view. + * + * @remarks + * Typically corresponds to the index of the selected personalization entry. + */ variantIndex: z.number(), }) + +/** + * TypeScript type inferred from {@link ComponentViewEvent}. + */ export type ComponentViewEvent = z.infer diff --git a/universal/api-schemas/src/experience/event/ExperienceEvent.ts b/universal/api-schemas/src/experience/event/ExperienceEvent.ts index 24cdefc0..508fcf4e 100644 --- a/universal/api-schemas/src/experience/event/ExperienceEvent.ts +++ b/universal/api-schemas/src/experience/event/ExperienceEvent.ts @@ -7,6 +7,13 @@ import { PageViewEvent } from './PageViewEvent' import { ScreenViewEvent } from './ScreenViewEvent' import { TrackEvent } from './TrackEvent' +/** + * Zod schema describing any supported experience/personalization event. + * + * @remarks + * This discriminated union aggregates all event types used by the + * personalization/experience tracking system. + */ export const ExperienceEvent = z.discriminatedUnion('type', [ AliasEvent, ComponentViewEvent, @@ -16,9 +23,23 @@ export const ExperienceEvent = z.discriminatedUnion('type', [ ScreenViewEvent, TrackEvent, ]) + +/** + * TypeScript type inferred from {@link ExperienceEvent}. + */ export type ExperienceEvent = z.infer +/** + * Union of all possible `type` values for {@link ExperienceEvent}. + */ export type ExperienceEventType = ExperienceEvent['type'] +/** + * Zod schema describing an array of {@link ExperienceEvent} items. + */ export const ExperienceEventArray = z.array(ExperienceEvent) + +/** + * TypeScript type inferred from {@link ExperienceEventArray}. + */ export type ExperienceEventArray = z.infer diff --git a/universal/api-schemas/src/experience/event/GroupEvent.ts b/universal/api-schemas/src/experience/event/GroupEvent.ts index 746dcbc8..92821109 100644 --- a/universal/api-schemas/src/experience/event/GroupEvent.ts +++ b/universal/api-schemas/src/experience/event/GroupEvent.ts @@ -1,7 +1,25 @@ import * as z from 'zod/mini' import { UniversalEventProperties } from './UniversalEventProperties' +/** + * Zod schema describing a `group` event. + * + * @remarks + * Currently unused. + * + * Group events typically associate a user with an organization, account, + * or other grouping construct. + * + * Extends {@link UniversalEventProperties}. + */ export const GroupEvent = z.extend(UniversalEventProperties, { + /** + * Discriminator indicating that this event is a group event. + */ type: z.literal('group'), }) + +/** + * TypeScript type inferred from {@link GroupEvent}. + */ export type GroupEvent = z.infer diff --git a/universal/api-schemas/src/experience/event/IdentifyEvent.ts b/universal/api-schemas/src/experience/event/IdentifyEvent.ts index 97201e5c..b837199c 100644 --- a/universal/api-schemas/src/experience/event/IdentifyEvent.ts +++ b/universal/api-schemas/src/experience/event/IdentifyEvent.ts @@ -2,8 +2,29 @@ import * as z from 'zod/mini' import { UniversalEventProperties } from './UniversalEventProperties' import { Traits } from './properties/Traits' +/** + * Zod schema describing an `identify` event. + * + * @remarks + * Identify events attach user traits to a known identity. + * + * Extends {@link UniversalEventProperties} with a `traits` payload. + */ export const IdentifyEvent = z.extend(UniversalEventProperties, { + /** + * Discriminator indicating that this event is an identify event. + */ type: z.literal('identify'), + + /** + * Traits describing the user. + * + * @see {@link Traits} + */ traits: Traits, }) + +/** + * TypeScript type inferred from {@link IdentifyEvent}. + */ export type IdentifyEvent = z.infer diff --git a/universal/api-schemas/src/experience/event/PageViewEvent.ts b/universal/api-schemas/src/experience/event/PageViewEvent.ts index d15d9949..4771cfba 100644 --- a/universal/api-schemas/src/experience/event/PageViewEvent.ts +++ b/universal/api-schemas/src/experience/event/PageViewEvent.ts @@ -2,9 +2,36 @@ import * as z from 'zod/mini' import { UniversalEventProperties } from './UniversalEventProperties' import { Page } from './properties' +/** + * Zod schema describing a `page` view event. + * + * @remarks + * Page view events track visits to web pages and associated context. + * + * Extends {@link UniversalEventProperties} with optional `name` and + * page-level {@link Page} properties. + */ export const PageViewEvent = z.extend(UniversalEventProperties, { + /** + * Discriminator indicating that this event is a page view. + */ type: z.literal('page'), + + /** + * Optional name for the page. + * + * @remarks + * Useful when the logical page name differs from the URL or title. + */ name: z.optional(z.string()), + + /** + * Page-level properties such as URL, path, and referrer. + */ properties: Page, }) + +/** + * TypeScript type inferred from {@link PageViewEvent}. + */ export type PageViewEvent = z.infer diff --git a/universal/api-schemas/src/experience/event/ScreenViewEvent.ts b/universal/api-schemas/src/experience/event/ScreenViewEvent.ts index 1ba878f2..5711cc4d 100644 --- a/universal/api-schemas/src/experience/event/ScreenViewEvent.ts +++ b/universal/api-schemas/src/experience/event/ScreenViewEvent.ts @@ -1,9 +1,36 @@ import * as z from 'zod/mini' import { UniversalEventProperties } from './UniversalEventProperties' -import { Screen } from './properties' +import { Properties } from './properties' +/** + * Zod schema describing a `screen` view event. + * + * @remarks + * Screen view events are typically used by mobile or TV applications + * to track navigation between screens. + * + * Extends {@link UniversalEventProperties}. + */ export const ScreenViewEvent = z.extend(UniversalEventProperties, { + /** + * Discriminator indicating that this event is a screen view. + */ type: z.literal('screen'), - properties: Screen, + + /** + * Name of the screen being viewed. + */ + name: z.string(), + + /** + * Additional properties describing the screen context. + * + * @see Properties + */ + properties: Properties, }) + +/** + * TypeScript type inferred from {@link ScreenViewEvent}. + */ export type ScreenViewEvent = z.infer diff --git a/universal/api-schemas/src/experience/event/TrackEvent.ts b/universal/api-schemas/src/experience/event/TrackEvent.ts index a93de8a1..3bd17b64 100644 --- a/universal/api-schemas/src/experience/event/TrackEvent.ts +++ b/universal/api-schemas/src/experience/event/TrackEvent.ts @@ -2,9 +2,33 @@ import * as z from 'zod/mini' import { UniversalEventProperties } from './UniversalEventProperties' import { Properties } from './properties' +/** + * Zod schema describing a custom `track` event. + * + * @remarks + * Track events capture arbitrary user actions that do not fit into + * the more specific event types (page, screen, identify, etc.). + * + * Extends {@link UniversalEventProperties}. + */ export const TrackEvent = z.extend(UniversalEventProperties, { + /** + * Discriminator indicating that this event is a track event. + */ type: z.literal('track'), + + /** + * Name of the event being tracked. + */ event: z.string(), + + /** + * Additional properties describing the event. + */ properties: Properties, }) + +/** + * TypeScript type inferred from {@link TrackEvent}. + */ export type TrackEvent = z.infer diff --git a/universal/api-schemas/src/experience/event/UniversalEventProperties.ts b/universal/api-schemas/src/experience/event/UniversalEventProperties.ts index 495785d5..30876a24 100644 --- a/universal/api-schemas/src/experience/event/UniversalEventProperties.ts +++ b/universal/api-schemas/src/experience/event/UniversalEventProperties.ts @@ -1,26 +1,116 @@ import * as z from 'zod/mini' import { App, Campaign, Channel, GeoLocation, Library, Page } from './properties' -// TODO: Rework event typing to handle multiple channels (this is harder than it seems) +/** + * Zod schema describing universal properties shared by all analytics events. + * + * @remarks + * These properties capture common metadata such as channel, context, + * timestamps, and user identifiers. + */ export const UniversalEventProperties = z.object({ + /** + * Channel from which the event originated. + * + * @see Channel + */ channel: Channel, + + /** + * Context object carrying environment and client metadata. + */ context: z.object({ + /** + * Application-level metadata. + */ app: App, + + /** + * Campaign attribution metadata. + */ campaign: Campaign, + + /** + * GDPR-related consent information. + */ gdpr: z.object({ + /** + * Indicates whether the user has given consent. + */ isConsentGiven: z.boolean(), }), + + /** + * Analytics library metadata. + */ library: Library, + + /** + * Locale identifier of the event (e.g., `"en-US"`). + */ locale: z.string(), + + /** + * Optional geo-location information associated with the event. + */ location: z.optional(GeoLocation), + + /** + * Page context for events that occur within a web page. + * + * @remarks + * May be populated even for events that are not explicitly page views. + * + * @privateRemarks + * This may be removed from the APIs and the SDKs in the near future. + */ page: Page, + + /** + * User agent string of the client, if available. + */ userAgent: z.optional(z.string()), }), + + /** + * Unique identifier for this message. + * + * @remarks + * Used to deduplicate events across retries and transports. + */ messageId: z.string(), + + /** + * Timestamp when the event originally occurred. + * + * @remarks + * ISO 8601 datetime string. + */ originalTimestamp: z.iso.datetime(), + + /** + * Timestamp when the event payload was sent. + * + * @remarks + * ISO 8601 datetime string. + */ sentAt: z.iso.datetime(), + + /** + * Timestamp when the event was recorded or processed. + * + * @remarks + * ISO 8601 datetime string. + */ timestamp: z.iso.datetime(), + + /** + * Identifier of the authenticated user, if known. + */ userId: z.optional(z.string()), }) +/** + * TypeScript type inferred from {@link UniversalEventProperties}. + */ export type UniversalEventProperties = z.infer diff --git a/universal/api-schemas/src/experience/event/properties/App.ts b/universal/api-schemas/src/experience/event/properties/App.ts index 52c2a4d1..1adf46ea 100644 --- a/universal/api-schemas/src/experience/event/properties/App.ts +++ b/universal/api-schemas/src/experience/event/properties/App.ts @@ -1,10 +1,29 @@ import * as z from 'zod/mini' +/** + * Zod schema describing app-level properties. + * + * @remarks + * These properties typically describe the application that is emitting + * analytics events, such as its name and version. + * + * The entire object is optional; when omitted, no app context is attached. + */ export const App = z.optional( z.object({ + /** + * Name of the application. + */ name: z.string(), + + /** + * Version of the application. + */ version: z.string(), }), ) +/** + * TypeScript type inferred from {@link App}. + */ export type App = z.infer diff --git a/universal/api-schemas/src/experience/event/properties/Campaign.ts b/universal/api-schemas/src/experience/event/properties/Campaign.ts index dc02230c..d0eed6e2 100644 --- a/universal/api-schemas/src/experience/event/properties/Campaign.ts +++ b/universal/api-schemas/src/experience/event/properties/Campaign.ts @@ -1,10 +1,39 @@ import * as z from 'zod/mini' +/** + * Zod schema describing campaign attribution properties. + * + * @remarks + * These fields typically mirror UTM parameters used for marketing campaigns. + */ export const Campaign = z.object({ + /** + * Name of the campaign (e.g., `utm_campaign`). + */ name: z.optional(z.string()), + + /** + * Campaign source (e.g., `utm_source`). + */ source: z.optional(z.string()), + + /** + * Campaign medium (e.g., `utm_medium`). + */ medium: z.optional(z.string()), + + /** + * Campaign term (e.g., `utm_term`). + */ term: z.optional(z.string()), + + /** + * Campaign content (e.g., `utm_content`). + */ content: z.optional(z.string()), }) + +/** + * TypeScript type inferred from {@link Campaign}. + */ export type Campaign = z.infer diff --git a/universal/api-schemas/src/experience/event/properties/Channel.ts b/universal/api-schemas/src/experience/event/properties/Channel.ts index ff13b8ec..1d32b37f 100644 --- a/universal/api-schemas/src/experience/event/properties/Channel.ts +++ b/universal/api-schemas/src/experience/event/properties/Channel.ts @@ -1,4 +1,18 @@ import * as z from 'zod/mini' +/** + * Zod schema describing the analytics channel. + * + * @remarks + * Indicates the execution environment where the event originated. + * + * - `'mobile'` — Events from native or hybrid mobile apps. + * - `'server'` — Events emitted from backend/server-side code. + * - `'web'` — Events from web browsers or web-based clients. + */ export const Channel = z.union([z.literal('mobile'), z.literal('server'), z.literal('web')]) + +/** + * TypeScript type inferred from {@link Channel}. + */ export type Channel = z.infer diff --git a/universal/api-schemas/src/experience/event/properties/Dictionary.ts b/universal/api-schemas/src/experience/event/properties/Dictionary.ts index c0fcb46d..22ec504e 100644 --- a/universal/api-schemas/src/experience/event/properties/Dictionary.ts +++ b/universal/api-schemas/src/experience/event/properties/Dictionary.ts @@ -1,4 +1,14 @@ import * as z from 'zod/mini' +/** + * Zod schema describing a simple string-to-string dictionary. + * + * @remarks + * Commonly used for query parameters or other small key–value maps. + */ export const Dictionary = z.record(z.string(), z.string()) + +/** + * TypeScript type inferred from {@link Dictionary}. + */ export type Dictionary = z.infer diff --git a/universal/api-schemas/src/experience/event/properties/GeoLocation.ts b/universal/api-schemas/src/experience/event/properties/GeoLocation.ts index eeb112d8..901ab1f8 100644 --- a/universal/api-schemas/src/experience/event/properties/GeoLocation.ts +++ b/universal/api-schemas/src/experience/event/properties/GeoLocation.ts @@ -1,21 +1,92 @@ import * as z from 'zod/mini' +/** + * Length (in characters) of the expected country code. + * + * @remarks + * Typically corresponds to ISO 3166-1 alpha-2 country codes. + */ const COUNTRY_CODE_LENGTH = 2 +/** + * Zod schema describing geographical coordinates. + * + * @remarks + * Latitude and longitude are expressed in decimal degrees. + */ const Coordinates = z.object({ + /** + * Latitude component of the coordinates. + */ latitude: z.number(), + + /** + * Longitude component of the coordinates. + */ longitude: z.number(), }) +/** + * Zod schema describing geo-location properties associated with an event. + * + * @remarks + * All properties are optional and may be derived from IP or device data. + */ export const GeoLocation = z.object({ + /** + * Geographical coordinates for the location. + */ coordinates: z.optional(Coordinates), + + /** + * City name associated with the location. + */ city: z.optional(z.string()), + + /** + * Postal or ZIP code associated with the location. + */ postalCode: z.optional(z.string()), + + /** + * Region or state name associated with the location. + */ region: z.optional(z.string()), + + /** + * Region or state code associated with the location. + */ regionCode: z.optional(z.string()), + + /** + * Country name associated with the location. + */ country: z.optional(z.string()), + + /** + * Country code associated with the location. + * + * @remarks + * Validated to exactly COUNTRY_CODE_LENGTH characters, typically + * an ISO 3166-1 alpha-2 code. + */ countryCode: z.optional(z.string().check(z.length(COUNTRY_CODE_LENGTH))), + + /** + * Continent name associated with the location. + */ continent: z.optional(z.string()), + + /** + * Time zone identifier associated with the location. + * + * @remarks + * Typically an IANA time zone string (e.g., `"Europe/Berlin"`). + */ timezone: z.optional(z.string()), }) + +/** + * TypeScript type inferred from {@link GeoLocation}. + */ export type GeoLocation = z.infer diff --git a/universal/api-schemas/src/experience/event/properties/Library.ts b/universal/api-schemas/src/experience/event/properties/Library.ts index d46c5414..aaa5f893 100644 --- a/universal/api-schemas/src/experience/event/properties/Library.ts +++ b/universal/api-schemas/src/experience/event/properties/Library.ts @@ -1,8 +1,24 @@ import * as z from 'zod/mini' +/** + * Zod schema describing analytics library metadata. + * + * @remarks + * Identifies the client library that produced the event. + */ export const Library = z.object({ + /** + * Name of the SDK/library (e.g., `"@contentful/optimization-web"`). + */ name: z.string(), + + /** + * Version of the analytics library. + */ version: z.string(), }) +/** + * TypeScript type inferred from {@link Library}. + */ export type Library = z.infer diff --git a/universal/api-schemas/src/experience/event/properties/Page.ts b/universal/api-schemas/src/experience/event/properties/Page.ts index 763c164f..882a4e43 100644 --- a/universal/api-schemas/src/experience/event/properties/Page.ts +++ b/universal/api-schemas/src/experience/event/properties/Page.ts @@ -1,15 +1,49 @@ import * as z from 'zod/mini' import { Dictionary } from './Dictionary' +/** + * Zod schema describing Web page-level properties for events. + * + * @remarks + * The base object describes standard page attributes, while additional + * JSON properties may be present due to the use of `z.catchall`. + */ export const Page = z.catchall( z.object({ + /** + * Path component of the page URL (e.g., `/products/123`). + */ path: z.string(), + + /** + * Parsed query parameters for the page. + */ query: Dictionary, + + /** + * Referrer URL that led to the current page. + */ referrer: z.string(), + + /** + * Raw search string including the leading `?` (e.g., `"?q=test"`). + */ search: z.string(), + + /** + * Title of the page as seen by the user. + */ title: z.optional(z.string()), + + /** + * Full URL of the page. + */ url: z.string(), }), z.json(), ) + +/** + * TypeScript type inferred from {@link Page}. + */ export type Page = z.infer diff --git a/universal/api-schemas/src/experience/event/properties/Properties.ts b/universal/api-schemas/src/experience/event/properties/Properties.ts index 5e353356..d8d4bb29 100644 --- a/universal/api-schemas/src/experience/event/properties/Properties.ts +++ b/universal/api-schemas/src/experience/event/properties/Properties.ts @@ -1,4 +1,14 @@ import * as z from 'zod/mini' +/** + * Zod schema describing a generic collection of event properties. + * + * @remarks + * Represents an arbitrary JSON-serializable map from string keys to values. + */ export const Properties = z.record(z.string(), z.json()) + +/** + * TypeScript type inferred from {@link Properties}. + */ export type Properties = z.infer diff --git a/universal/api-schemas/src/experience/event/properties/Screen.ts b/universal/api-schemas/src/experience/event/properties/Screen.ts deleted file mode 100644 index c9ff8f43..00000000 --- a/universal/api-schemas/src/experience/event/properties/Screen.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as z from 'zod/mini' -import { Dictionary } from './Dictionary' - -export const Screen = z.catchall( - z.object({ - name: z.string(), - facets: z.optional(Dictionary), - route: z.optional(z.string()), - title: z.optional(z.string()), - }), - z.json(), -) -export type Screen = z.infer diff --git a/universal/api-schemas/src/experience/event/properties/Traits.ts b/universal/api-schemas/src/experience/event/properties/Traits.ts index 8673896f..b8f6b4d9 100644 --- a/universal/api-schemas/src/experience/event/properties/Traits.ts +++ b/universal/api-schemas/src/experience/event/properties/Traits.ts @@ -1,4 +1,15 @@ import * as z from 'zod/mini' +/** + * Zod schema describing user traits / identity properties. + * + * @remarks + * Represents an arbitrary JSON-serializable map from string keys to values. + * Common traits may include `name`, `plan`, and custom attributes. + */ export const Traits = z.record(z.string(), z.json()) + +/** + * TypeScript type inferred from {@link Traits}. + */ export type Traits = z.infer diff --git a/universal/api-schemas/src/experience/event/properties/index.ts b/universal/api-schemas/src/experience/event/properties/index.ts index 53065c85..8d41c519 100644 --- a/universal/api-schemas/src/experience/event/properties/index.ts +++ b/universal/api-schemas/src/experience/event/properties/index.ts @@ -6,5 +6,4 @@ export * from './GeoLocation' export * from './Library' export * from './Page' export * from './Properties' -export * from './Screen' export * from './Traits' diff --git a/universal/api-schemas/src/experience/personalization/SelectedPersonalization.ts b/universal/api-schemas/src/experience/personalization/SelectedPersonalization.ts new file mode 100644 index 00000000..85b7084a --- /dev/null +++ b/universal/api-schemas/src/experience/personalization/SelectedPersonalization.ts @@ -0,0 +1,62 @@ +import * as z from 'zod/mini' + +/** + * Zod schema describing a selected personalization outcome for a user. + * + * @remarks + * Represents the result of choosing a specific variant for a given + * experience, along with additional metadata such as whether the + * selection is sticky. + */ +export const SelectedPersonalization = z.object({ + /** + * Identifier of the personalization or experiment experience. + */ + experienceId: z.string(), + + /** + * Index of the selected variant within the experience configuration. + * + * @remarks + * Typically corresponds to the index of the selected {@link PersonalizationConfig } entry. + */ + variantIndex: z.number(), + + /** + * Mapping of baseline entry IDs to their selected variant entry IDs. + * + * @remarks + * The keys are component identifiers and the values are the + * identifiers of the selected variant for that component. + */ + variants: z.record(z.string(), z.string()), + + /** + * Indicates whether this personalization selection is sticky for the user. + * + * @defaultValue false + * + * @remarks + * Sticky selections should be reused on subsequent requests for the + * same user, rather than re-allocating a new variant. + */ + sticky: z.optional(z.prefault(z.boolean(), false)), +}) + +/** + * TypeScript type inferred from {@link SelectedPersonalization}. + */ +export type SelectedPersonalization = z.infer + +/** + * Zod schema describing an array of {@link SelectedPersonalization} items. + * + * @remarks + * Useful when multiple experiences are evaluated at once. + */ +export const SelectedPersonalizationArray = z.array(SelectedPersonalization) + +/** + * TypeScript type inferred from {@link SelectedPersonalizationArray}. + */ +export type SelectedPersonalizationArray = z.infer diff --git a/universal/api-schemas/src/experience/personalization/SelectedPersonalizations.ts b/universal/api-schemas/src/experience/personalization/SelectedPersonalizations.ts deleted file mode 100644 index 4e29901e..00000000 --- a/universal/api-schemas/src/experience/personalization/SelectedPersonalizations.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as z from 'zod/mini' - -export const SelectedPersonalization = z.object({ - experienceId: z.string(), - variantIndex: z.number(), - variants: z.record(z.string(), z.string()), - sticky: z.optional(z.prefault(z.boolean(), false)), -}) -export type SelectedPersonalization = z.infer - -export const SelectedPersonalizationArray = z.array(SelectedPersonalization) -export type SelectedPersonalizationArray = z.infer diff --git a/universal/api-schemas/src/experience/personalization/index.ts b/universal/api-schemas/src/experience/personalization/index.ts index 176bacf3..6da26d7b 100644 --- a/universal/api-schemas/src/experience/personalization/index.ts +++ b/universal/api-schemas/src/experience/personalization/index.ts @@ -1 +1 @@ -export * from './SelectedPersonalizations' +export * from './SelectedPersonalization' diff --git a/universal/api-schemas/src/experience/profile/Profile.ts b/universal/api-schemas/src/experience/profile/Profile.ts index 2aca90f0..81ca3c39 100644 --- a/universal/api-schemas/src/experience/profile/Profile.ts +++ b/universal/api-schemas/src/experience/profile/Profile.ts @@ -3,23 +3,90 @@ import { GeoLocation } from '../event/properties' import { Traits } from '../event/properties/Traits' import { SessionStatistics } from './properties' -// Received from the Experience API +/** + * Zod schema describing a full user profile as received from the Experience API. + * + * @remarks + * Represents the server-side view of a profile, including identifiers, + * traits, audiences, location, and session statistics. + */ export const Profile = z.object({ + /** + * Primary identifier of the profile. + */ id: z.string(), + + /** + * Stable, long-lived identifier of the profile. + * + * @remarks + * Intended to remain constant across sessions and devices when possible. + * Usually equal to `id`. + */ stableId: z.string(), + + /** + * Random value associated with the profile. + * + * @remarks + * Often used for deterministic bucketing (e.g., in experiments). + */ random: z.number(), + + /** + * List of audience identifiers that this profile currently belongs to. + */ audiences: z.array(z.string()), + + /** + * Traits describing the profile (user-level attributes). + * + * @see Traits + */ traits: Traits, + + /** + * Geo-location information associated with the profile. + * + * @see GeoLocation + */ location: GeoLocation, + + /** + * Aggregated session statistics for the profile. + * + * @see SessionStatistics + */ session: SessionStatistics, }) + +/** + * TypeScript type inferred from {@link Profile}. + */ export type Profile = z.infer -// Used for sending events to the Experience & Insights APIs +/** + * Zod schema describing a partial profile payload used for sending events + * to the Experience & Insights APIs. + * + * @remarks + * This schema enforces the presence of an `id` field and allows additional + * JSON-serializable properties via `z.catchall`. + */ export const PartialProfile = z.catchall( z.object({ + /** + * Identifier of the profile. + * + * @remarks + * Used to associate events with an existing profile. + */ id: z.string(), }), z.json(), ) + +/** + * TypeScript type inferred from {@link PartialProfile}. + */ export type PartialProfile = z.infer diff --git a/universal/api-schemas/src/experience/profile/properties/SessionStatistics.ts b/universal/api-schemas/src/experience/profile/properties/SessionStatistics.ts index 00480404..2c59d077 100644 --- a/universal/api-schemas/src/experience/profile/properties/SessionStatistics.ts +++ b/universal/api-schemas/src/experience/profile/properties/SessionStatistics.ts @@ -1,12 +1,60 @@ import * as z from 'zod/mini' import { Page } from '../../event/properties' +/** + * Zod schema describing aggregated statistics for a user session. + * + * @remarks + * Captures both per-session metrics (such as `activeSessionLength`) and + * aggregate metrics (such as `averageSessionLength`) for a given profile. + */ export const SessionStatistics = z.object({ + /** + * Unique identifier for this session statistics record. + */ id: z.string(), + + /** + * Indicates whether the visitor has been seen before. + * + * @remarks + * `true` typically means the visitor has at least one prior session. + */ isReturningVisitor: z.boolean(), + + /** + * Landing page for the session. + * + * @remarks + * Represents the first page the user visited in this session. + * + * @see Page + */ landingPage: Page, + + /** + * Number of sessions associated with this profile or identifier. + * + * @remarks + * Often used in combination with {@link SessionStatistics.averageSessionLength}. + */ count: z.number(), + + /** + * Duration of the active session. + */ activeSessionLength: z.number(), + + /** + * Average session duration across all sessions represented by this record. + * + * @remarks + * The unit should match {@link SessionStatistics.activeSessionLength}. + */ averageSessionLength: z.number(), }) + +/** + * TypeScript type inferred from {@link SessionStatistics}. + */ export type SessionStatistics = z.infer diff --git a/universal/api-schemas/src/insights/event/BatchInsightsEvent.ts b/universal/api-schemas/src/insights/event/BatchInsightsEvent.ts index d61d6776..056b6749 100644 --- a/universal/api-schemas/src/insights/event/BatchInsightsEvent.ts +++ b/universal/api-schemas/src/insights/event/BatchInsightsEvent.ts @@ -2,11 +2,43 @@ import * as z from 'zod/mini' import { PartialProfile } from '../../experience/profile' import { InsightsEventArray } from './InsightsEvent' +/** + * Zod schema describing a batched Insights event payload. + * + * @remarks + * Combines a {@link PartialProfile} with one or more Insights events + * to be sent to the Contentful Insights API. + */ export const BatchInsightsEvent = z.object({ + /** + * Partial profile information used to associate events with a user. + * + * @see PartialProfile + */ profile: PartialProfile, + + /** + * Insights events that should be recorded for this profile. + * + * @see InsightsEventArray + */ events: InsightsEventArray, }) + +/** + * TypeScript type inferred from {@link BatchInsightsEvent}. + */ export type BatchInsightsEvent = z.infer +/** + * Zod schema describing an array of {@link BatchInsightsEvent} items. + * + * @remarks + * Useful when sending multiple profile/event batches in a single request. + */ export const BatchInsightsEventArray = z.array(BatchInsightsEvent) + +/** + * TypeScript type inferred from {@link BatchInsightsEventArray}. + */ export type BatchInsightsEventArray = z.infer diff --git a/universal/api-schemas/src/insights/event/InsightsEvent.ts b/universal/api-schemas/src/insights/event/InsightsEvent.ts index d3cdbf28..e000a864 100644 --- a/universal/api-schemas/src/insights/event/InsightsEvent.ts +++ b/universal/api-schemas/src/insights/event/InsightsEvent.ts @@ -1,10 +1,32 @@ import * as z from 'zod/mini' import { ComponentViewEvent } from '../../experience/event' +/** + * Zod schema describing an Insights event. + * + * @remarks + * Currently, Insights events are limited to {@link ComponentViewEvent}, + * but this discriminated union can be extended with additional event + * types in the future. + */ export const InsightsEvent = z.discriminatedUnion('type', [ComponentViewEvent]) + +/** + * TypeScript type inferred from {@link InsightsEvent}. + */ export type InsightsEvent = z.infer +/** + * Union of all possible `type` values for {@link InsightsEvent}. + */ export type InsightsEventType = InsightsEvent['type'] +/** + * Zod schema describing an array of {@link InsightsEvent} items. + */ export const InsightsEventArray = z.array(InsightsEvent) + +/** + * TypeScript type inferred from {@link InsightsEventArray}. + */ export type InsightsEventArray = z.infer diff --git a/universal/core/README.md b/universal/core/README.md index d8317760..3a7e9f07 100644 --- a/universal/core/README.md +++ b/universal/core/README.md @@ -1,5 +1,356 @@ -# Optimization Core Library +

+ + Contentful Logo + +

-This library implements core logic and functionality that will be used in all Optimization SDKs. +

Contentful Personalization & Analytics

-This library is part of the [Contentful Optimization SDK Suite](../../../README.md). +

Core SDK

+ +
+ +[Readme](./README.md) · [Reference](https://contentful.github.io/optimization) · +[Contributing](/CONTRIBUTING.md) + +
+ +The Optimization Core SDK encapsulates all platform-agnostic functionality and business logic. All +other SDKs descend from the Core SDK. + +
+ Table of Contents + + +- [Getting Started](#getting-started) +- [Working with Stateless Core](#working-with-stateless-core) +- [Working with Stateful Core](#working-with-stateful-core) +- [Configuration](#configuration) + - [Top-level Configuration Options](#top-level-configuration-options) + - [Analytics Options](#analytics-options) + - [Event Builder Options](#event-builder-options) + - [Fetch Options](#fetch-options) + - [Personalization Options](#personalization-options) +- [Core Methods](#core-methods) + - [Personalization Data Resolution Methods](#personalization-data-resolution-methods) + - [`getCustomFlag`](#getcustomflag) + - [`personalizeEntry`](#personalizeentry) + - [`getMergeTagValue`](#getmergetagvalue) + - [Personalization and Analytics Event Methods](#personalization-and-analytics-event-methods) + - [`identify`](#identify) + - [`page`](#page) + - [`track`](#track) + - [`trackComponentView`](#trackcomponentview) + - [`trackFlagView`](#trackflagview) +- [Stateful-only Core Methods and Properties](#stateful-only-core-methods-and-properties) + + +
+ +## Getting Started + +Install using an NPM-compatible package manager, pnpm for example: + +```sh +pnpm install @contentful/optimization-core +``` + +Import either the stateful or stateless Core class, depending on the target environment; both CJS +and ESM module systems are supported, ESM preferred: + +```ts +import { CoreStateful } from '@contentful/optimization-core' +// or +import { CoreStateless } from '@contentful/optimization-core' +``` + +Configure and initialize the Core SDK: + +```ts +const optimization = new CoreStateful({ clientId: 'abc123' }) +// or +const optimization = new CoreStateless({ clientId: 'abc123' }) +``` + +## Working with Stateless Core + +The `CoreStateless` class is intended to be used as the basis for SDKs that would run in stateless +environments such as Node-based servers and server-side functions in meta-frameworks such as +Next.js. + +In stateless environments, Core will not maintain any internal state, which includes user consent. +These concerns should be handled by consumers to fit their specific architectural and design +specifications. + +## Working with Stateful Core + +The `CoreStateful` class is intended to be used as the basis for SDKs that would run in stateful +environments such as Web front-ends and mobile applications (via JavaScript runtime containers). + +In stateful environments, Core maintains state internally for consent, an event stream, and +profile-related data that is commonly obtained from requests to the Experience API. These states are +exposed externally as read-only observables. + +## Configuration + +### Top-level Configuration Options + +| Option | Required? | Default | Description | +| ----------------- | --------- | ----------------------------- | --------------------------------------------------------------------- | +| `analytics` | No | See "Analytics Options" | Configuration specific to the Analytics/Insights API | +| `clientId` | Yes | N/A | The Ninetailed API Key which can be found in the Ninetailed Admin app | +| `environment` | No | `'main'` | The Ninetailed environment configured in the Ninetailed Admin app | +| `eventBuilder` | No | See "Event Builder Options" | Event builder configuration (channel/library metadata, etc.) | +| `fetchOptions` | No | See "Fetch Options" | Configuration for Fetch timeout and retry functionality | +| `logLevel` | No | `'error'` | Minimum log level for the default console sin | +| `personalization` | No | See "Personalization Options" | Configuration specific to the Personalization/Experience API | + +The following configuration options apply only in stateful environments: + +| Option | Required? | Default | Description | +| -------------------------- | --------- | ---------------------- | ----------------------------------------------------------------- | +| `allowedEventTypes` | No | `['identify', 'page']` | Allow-listed event types permitted when consent is not set | +| `defaults` | No | `undefined` | Set of default state values applied on initialization | +| `getAnonymousId` | No | `undefined` | Function used to obtain an anonymous user identifier | +| `preventedComponentEvents` | No | `undefined` | Initial duplication prevention configuration for component events | + +Configuration method signatures: + +- `getAnonymousId`: `() => string | undefined` + +### Analytics Options + +| Option | Required? | Default | Description | +| --------------- | --------- | ------------------------------------------ | ------------------------------------------------------------------------ | +| `baseUrl` | No | `'https://ingest.insights.ninetailed.co/'` | Base URL for the Insights API | +| `beaconHandler` | No | `undefined` | Handler used to enqueue events via the Beacon API or a similar mechanism | + +Configuration method signatures: + +- `beaconHandler`: `(url: string | URL, data: BatchInsightsEventArray) => boolean` + +### Event Builder Options + +Event builder options should only be supplied when building an SDK on top of Core or any of its +descendent SDKs. + +| Option | Required? | Default | Description | +| ------------------- | --------- | ------------------------------- | ---------------------------------------------------------------------------------- | +| `app` | No | `undefined` | The application definition used to attribute events to a specific consumer app | +| `channel` | Yes | N/A | The channel that identifies where events originate from (e.g. `'web'`, `'mobile'`) | +| `library` | Yes | N/A | The client library metadata that is attached to all events | +| `getLocale` | No | `() => 'en-US'` | Function used to resolve the locale for outgoing events | +| `getPageProperties` | No | `() => DEFAULT_PAGE_PROPERTIES` | Function that returns the current page properties | +| `getUserAgent` | No | `() => undefined` | Function used to obtain the current user agent string when applicable | + +The `get*` functions are most useful in stateful environments. Stateless environments should set the +related data directly via Event Builder method arguments. + +The `channel` option may contain one of the following values: + +- `web` +- `mobile` +- `server` + +Configuration method signatures: + +- `getLocale`: `() => string | undefined` +- `getPageProperties`: + + ```ts + () => { + path: string, + query: Record, + referrer: string, + search: string, + title?: string, + url: string + } + ``` + +- `getUserAgent`: `() => string | undefined` + +### Fetch Options + +Fetch options allow for configuration of both a Fetch API-compatible fetch method and the +retry/timeout logic integrated into the Optimization API Client. Specify the `fetchMethod` when the +host application environment does not offer a `fetch` method that is compatible with the standard +Fetch API in its global scope. + +| Option | Required? | Default | Description | +| ------------------ | --------- | ----------- | --------------------------------------------------------------------- | +| `fetchMethod` | No | `undefined` | Signature of a fetch method used by the API clients | +| `intervalTimeout` | No | `0` | Delay (in milliseconds) between retry attempts | +| `onFailedAttempt` | No | `undefined` | Callback invoked whenever a retry attempt fails | +| `onRequestTimeout` | No | `undefined` | Callback invoked when a request exceeds the configured timeout | +| `requestTimeout` | No | `3000` | Maximum time (in milliseconds) to wait for a response before aborting | +| `retries` | No | `1` | Maximum number of retry attempts | + +Configuration method signatures: + +- `fetchMethod`: `(url: string \| URL, init: RequestInit) => Promise` +- `onFailedAttempt` and `onRequestTimeout`: `(options: FetchMethodCallbackOptions) => void` + +### Personalization Options + +| Option | Required? | Default | Description | +| ----------------- | --------- | ------------------------------------- | ------------------------------------------------------------------- | +| `baseUrl` | No | `'https://experience.ninetailed.co/'` | Base URL for the Experience API | +| `enabledFeatures` | No | `['ip-enrichment', 'location']` | Enabled features which the API may use for each request | +| `ip` | No | `undefined` | IP address to override the API behavior for IP analysis | +| `locale` | No | `'en-US'` (in API) | Locale used to translate `location.city` and `location.country` | +| `plainText` | No | `false` | Sends performance-critical endpoints in plain text | +| `preflight` | No | `false` | Instructs the API to aggregate a new profile state but not store it | + +## Core Methods + +The methods in this section are available in both stateful and stateless Core classes. However, be +aware that there are some minor differences in argument usage between stateful and stateless Core +implementations. + +Arguments marked with an asterisk (\*) are always required. + +### Personalization Data Resolution Methods + +#### `getCustomFlag` + +Get the specified Custom Flag's value from the provided changes array, or from the current internal +state in stateful implementations. + +Arguments: + +- `name`\*: The name/key of the Custom Flag +- `changes`: Changes array + +Returns: + +- The resolved value for the specified Custom Flag, or `undefined` if it cannot be found. + +> [!NOTE] +> +> If the `changes` argument is omitted in stateless implementations, the method will return +> `undefined`. + +#### `personalizeEntry` + +Resolve a baseline Contentful entry to a personalized variant using the provided selected +personalizations, or from the current internal state in stateful implementations. + +Type arguments: + +- `S`: Entry skeleton type +- `M`: Chain modifiers +- `L`: Locale code + +Arguments: + +- `entry`\*: The entry to personalize +- `personalizations`: Selected personalizations + +Returns: + +- The resolved personalized entry variant, or the supplied baseline entry if baseline is the + selected variant or a variant cannot be found. + +> [!NOTE] +> +> If the `personalizations` argument is omitted in stateless implementations, the method will return +> the baseline entry. + +#### `getMergeTagValue` + +Resolve a "Merge Tag" to a value based on the current (or provided) profile. A "Merge Tag" is a +special Rich Text fragment supported by Contentful that specifies a profile data member to be +injected into the Rich Text when rendered. + +Arguments: + +- `embeddedNodeEntryTarget`\*: The merge tag entry node to resolve +- `profile`: The user profile + +> [!NOTE] +> +> If the `profile` argument is omitted in stateless implementations, the method will return the +> merge tag's fallback value. + +### Personalization and Analytics Event Methods + +Each method except `trackFlagView` may return an `OptimizationData` object containing: + +- `changes`: Currently used for Custom Flags +- `personalizations`: Selected personalizations for the profile +- `profile`: Profile associated with the evaluated events + +#### `identify` + +Identify the current profile/visitor to associate traits with a profile. + +Arguments: + +- `payload`\*: Identify event builder arguments object, including an optional `profile` property + with a `PartialProfile` value that requires only an `id` + +#### `page` + +Record a personalization page view. + +Arguments: + +- `payload`\*: Page view event builder arguments object, including an optional `profile` property + with a `PartialProfile` value that requires only an `id` + +#### `track` + +Record a personalization custom track event. + +Arguments: + +- `payload`\*: Track event builder arguments object, including an optional `profile` property with a + `PartialProfile` value that requires only an `id` + +#### `trackComponentView` + +Record an analytics component view event. When the payload marks the component as "sticky", an +additional personalization component view is recorded. This method only returns `OptimizationData` +when the component is marked as "sticky". + +Arguments: + +- `payload`\*: Component view event builder arguments object, including an optional `profile` + property with a `PartialProfile` value that requires only an `id` +- `duplicationScope`: Arbitrary string that may be used to scope component view duplication; used in + Stateful implementations + +#### `trackFlagView` + +Track a feature flag view via analytics. This is functionally the same as a non-sticky component +view event. + +Arguments: + +- `payload`\*: Component view event builder arguments object, including an optional `profile` + property with a `PartialProfile` value that requires only an `id` +- `duplicationScope`: Arbitrary string that may be used to scope component view duplication; used in + Stateful implementations + +## Stateful-only Core Methods and Properties + +The following methods are unique to stateful implementations: + +- `consent(accept: boolean)`: Update consent state +- `reset`: Reset internal state; consent is preserved + +The following properties are unique to stateful implementations: + +- `states`: Returns an object mapping of observables for all internal states + +The following observables are exposed via the `states` property: + +- `consent`: The current state of user consent +- `eventStream`: The latest event to be queued +- `flags`: All current resolved Custom Flags +- `profile`: The current user profile +- `personalizations`: The current collection of selected personalizations + +Each state except `consent` and `eventStream` is updated internally whenever a response from the +Experience API contains a new or updated respective state. diff --git a/universal/core/package.json b/universal/core/package.json index c37365d7..5a7d4a36 100644 --- a/universal/core/package.json +++ b/universal/core/package.json @@ -1,6 +1,6 @@ { "name": "@contentful/optimization-core", - "version": "1.0.0", + "version": "0.0.0", "license": "MIT", "type": "commonjs", "main": "./dist/index.cjs", diff --git a/universal/core/src/Consent.ts b/universal/core/src/Consent.ts index 1476450e..ae9988fa 100644 --- a/universal/core/src/Consent.ts +++ b/universal/core/src/Consent.ts @@ -1,9 +1,45 @@ +/** + * Controller for updating the current consent state. + * + * @internal + * @remarks + * Intended for internal wiring between Core classes and the consent signal/store. + */ export interface ConsentController { + /** + * Update the runtime consent state. + * + * @param accept - `true` when the user has granted consent; `false` otherwise. + */ consent: (accept: boolean) => void } +/** + * Contract implemented by classes that gate operations based on consent. + * + * @internal + * @remarks + * These methods are consumed by the `@guardedBy` decorator to decide whether to + * proceed with an operation and how to report blocked calls. + */ export interface ConsentGuard { - // TODO: Determine whether these methods can be hard-private + /** + * Determine whether the named operation is permitted given current consent and + * any allow‑list configuration. + * + * @param name - Logical operation/method name (e.g., `'track'`, `'page'`). + * @returns `true` if the operation may proceed; otherwise `false`. + * @remarks + * The mapping between method names and event type strings may be product‑specific. + */ hasConsent: (name: string) => boolean + + /** + * Hook invoked when an operation is blocked due to missing consent. + * + * @param name - The blocked operation/method name. + * @param args - The original call arguments, provided for diagnostics/telemetry. + * @returns Nothing. Implementations typically log and/or emit diagnostics. + */ onBlockedByConsent: (name: string, args: unknown[]) => void } diff --git a/universal/core/src/CoreBase.ts b/universal/core/src/CoreBase.ts index a549f9bc..ba335675 100644 --- a/universal/core/src/CoreBase.ts +++ b/universal/core/src/CoreBase.ts @@ -1,48 +1,93 @@ import ApiClient, { - EventBuilder, type ApiClientConfig, + type ChangeArray, + type ComponentViewBuilderArgs, + EventBuilder, type EventBuilderConfig, + type ExperienceApiClientConfig, type GlobalApiConfigProperties, + type IdentifyBuilderArgs, + type InsightsApiClientConfig, + type Json, + type MergeTagEntry, + type OptimizationData, + type PageViewBuilderArgs, + type PartialProfile, + type Profile, + type SelectedPersonalizationArray, + type TrackBuilderArgs, } from '@contentful/optimization-api-client' +import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful' import type { LogLevels } from 'logger' import { ConsoleLogSink, logger } from 'logger' import type AnalyticsBase from './analytics/AnalyticsBase' +import type { ResolvedData } from './personalization' import type PersonalizationBase from './personalization/PersonalizationBase' -import type { ProductConfig } from './ProductBase' -/** Options that may be passed to the Core constructor */ +/** + * Options for configuring the {@link CoreBase} runtime and underlying clients. + * + * @public + */ export interface CoreConfig extends Pick { - allowedEventTypes?: ProductConfig['allowedEventTypes'] - - preventedComponentEvents?: ProductConfig['preventedComponentEvents'] + /** + * Configuration for the personalization (Experience) API client. + */ + personalization?: Omit - /** The API client configuration object */ - api?: Pick + /** + * Configuration for the analytics (Insights) API client. + */ + analytics?: Omit + /** + * Event builder configuration (channel/library metadata, etc.). + */ eventBuilder?: EventBuilderConfig + /** Minimum log level for the default console sink. */ logLevel?: LogLevels } +/** + * Internal base that wires the API client, event builder, and logging. + * + * @internal + */ abstract class CoreBase { + /** Product implementation for analytics. */ abstract readonly analytics: AnalyticsBase + /** Product implementation for personalization. */ abstract readonly personalization: PersonalizationBase + + /** Shared Optimization API client instance. */ readonly api: ApiClient + /** Shared event builder instance. */ readonly eventBuilder: EventBuilder + /** Resolved core configuration (minus any name metadata). */ readonly config: Omit + /** + * Create the core with API client and logging preconfigured. + * + * @param config - Core configuration including API and builder options. + * @example + * ```ts + * const sdk = new CoreStateless({ clientId: 'abc123', environment: 'prod' }) + * ``` + */ constructor(config: CoreConfig) { this.config = config - const { api, eventBuilder, logLevel, environment, clientId, preview } = config + const { analytics, personalization, eventBuilder, logLevel, environment, clientId } = config logger.addSink(new ConsoleLogSink(logLevel)) const apiConfig = { - ...api, + ...analytics, + ...personalization, clientId, environment, - preview, } this.api = new ApiClient(apiConfig) @@ -54,6 +99,120 @@ abstract class CoreBase { }, ) } + + /** + * Get the value of a custom flag derived from a set of optimization changes. + * + * @param name - The flag key to resolve. + * @param changes - Optional change list to resolve from + * @returns The resolved JSON value for the flag if available. + * @remarks + * This is a convenience wrapper around personalization’s flag resolution. + */ + getCustomFlag(name: string, changes?: ChangeArray): Json { + return this.personalization.getCustomFlag(name, changes) + } + + /** + * Resolve a Contentful entry to the appropriate personalized variant (or + * return the baseline entry if no matching variant is selected). + * + * @typeParam S - Entry skeleton type. + * @typeParam M - Chain modifiers. + * @typeParam L - Locale code. + * @param entry - The baseline entry to resolve. + * @param personalizations - Optional selection array for the current profile. + * @returns {@link ResolvedData} containing the resolved entry and + * personalization metadata (if any). + */ + personalizeEntry< + S extends EntrySkeletonType, + M extends ChainModifiers = ChainModifiers, + L extends LocaleCode = LocaleCode, + >(entry: Entry, personalizations?: SelectedPersonalizationArray): ResolvedData { + return this.personalization.personalizeEntry(entry, personalizations) + } + + /** + * Resolve a merge-tag value from the given entry node and profile. + * + * @param embeddedEntryNodeTarget - The merge-tag entry node to resolve. + * @param profile - Optional profile used for value lookup. + * @returns The resolved value (typically a string) or `undefined` if not found. + */ + getMergeTagValue(embeddedEntryNodeTarget: MergeTagEntry, profile?: Profile): unknown { + return this.personalization.getMergeTagValue(embeddedEntryNodeTarget, profile) + } + + /** + * Convenience wrapper for sending an `identify` event via personalization. + * + * @param payload - Identify builder arguments. + * @returns The resulting {@link OptimizationData} for the identified user. + */ + async identify( + payload: IdentifyBuilderArgs & { profile?: PartialProfile }, + ): Promise { + return await this.personalization.identify(payload) + } + + /** + * Convenience wrapper for sending a `page` event via personalization. + * + * @param payload - Page view builder arguments. + * @returns The evaluated {@link OptimizationData} for this page view. + */ + async page( + payload: PageViewBuilderArgs & { profile?: PartialProfile }, + ): Promise { + return await this.personalization.page(payload) + } + + /** + * Convenience wrapper for sending a custom `track` event via personalization. + * + * @param payload - Track builder arguments. + * @returns The evaluated {@link OptimizationData} for this event. + */ + async track(payload: TrackBuilderArgs & { profile?: PartialProfile }): Promise { + return await this.personalization.track(payload) + } + + /** + * Track a component view in both personalization and analytics. + * + * @param payload - Component view builder arguments. When `payload.sticky` is + * `true`, the event will also be sent via personalization as a sticky + * component view. + * @param duplicationScope - Optional string used to scope duplication used in Stateful + * implementations + * @returns A promise that resolves when all delegated calls complete. + * @remarks + * The sticky behavior is delegated to personalization; analytics is always + * invoked regardless of `sticky`. + */ + async trackComponentView( + payload: ComponentViewBuilderArgs & { profile?: PartialProfile }, + duplicationScope?: string, + ): Promise { + if (payload.sticky) { + return await this.personalization.trackComponentView(payload, duplicationScope) + } + + await this.analytics.trackComponentView(payload, duplicationScope) + } + + /** + * Track a feature flag view via analytics. + * + * @param payload - Component view builder arguments used to build the flag view event. + * @param duplicationScope - Optional string used to scope duplication used in Stateful + * implementations + * @returns A promise that resolves when processing completes. + */ + async trackFlagView(payload: ComponentViewBuilderArgs, duplicationScope?: string): Promise { + await this.analytics.trackFlagView(payload, duplicationScope) + } } export default CoreBase diff --git a/universal/core/src/CoreStateful.ts b/universal/core/src/CoreStateful.ts index 55d4142e..aaacd6f4 100644 --- a/universal/core/src/CoreStateful.ts +++ b/universal/core/src/CoreStateful.ts @@ -11,34 +11,102 @@ import type { ConsentController } from './Consent' import CoreBase, { type CoreConfig } from './CoreBase' import { PersonalizationStateful, + type PersonalizationProductConfig, type PersonalizationProductConfigDefaults, type PersonalizationStates, } from './personalization' +import type { ProductConfig } from './ProductBase' import { consent, event, toObservable, type Observable } from './signals' +/** + * Combined observable state exposed by the stateful core. + * + * @public + * @see {@link AnalyticsStates} + * @see {@link PersonalizationStates} + */ export interface CoreStates extends AnalyticsStates, PersonalizationStates { + /** Current consent value (if any). */ consent: Observable + /** Stream of the most recent event emitted (analytics or personalization). */ eventStream: Observable } +/** + * Default values used to preconfigure the stateful core and products. + * + * @public + */ export interface CoreConfigDefaults { + /** Global consent default applied at construction time. */ consent?: boolean - personalization?: PersonalizationProductConfigDefaults - analytics?: AnalyticsProductConfigDefaults + /** Defaults forwarded to the personalization product. */ + personalization?: Omit + /** Defaults forwarded to the analytics product. */ + analytics?: Omit } +/** + * Configuration for {@link CoreStateful}. + * + * @public + * @see {@link CoreConfig} + */ export interface CoreStatefulConfig extends CoreConfig { + /** + * Allow-listed event type strings permitted when consent is not set. + * + * @see {@link ProductConfig.allowedEventTypes} + */ + allowedEventTypes?: ProductConfig['allowedEventTypes'] + + /** Optional set of default values applied on initialization. */ defaults?: CoreConfigDefaults + + /** Function used to obtain an anonymous user identifier. */ + getAnonymousId?: PersonalizationProductConfig['getAnonymousId'] + + /** + * Initial duplication prevention configuration for component events. + * + * @see {@link ProductConfig.preventedComponentEvents} + */ + preventedComponentEvents?: ProductConfig['preventedComponentEvents'] } +/** + * Core runtime that constructs stateful product instances and exposes shared + * states, including consent and the event stream. + * + * @public + * @remarks + * @see {@link CoreBase} + * @see {@link ConsentController} + */ class CoreStateful extends CoreBase implements ConsentController { + /** Stateful analytics product. */ readonly analytics: AnalyticsStateful + /** Stateful personalization product. */ readonly personalization: PersonalizationStateful + /** + * Create a stateful core with optional default consent and product defaults. + * + * @param config - Core and defaults configuration. + * @example + * ```ts + * const core = new CoreStateful({ + * clientId: 'app', + * environment: 'prod', + * defaults: { consent: true } + * }) + * core.consent(true) + * ``` + */ constructor(config: CoreStatefulConfig) { super(config) - const { allowedEventTypes, defaults, preventedComponentEvents } = config + const { allowedEventTypes, defaults, getAnonymousId, preventedComponentEvents } = config if (defaults?.consent !== undefined) { const { consent: defaultConsent } = defaults @@ -48,16 +116,26 @@ class CoreStateful extends CoreBase implements ConsentController { this.analytics = new AnalyticsStateful(this.api, this.eventBuilder, { allowedEventTypes, preventedComponentEvents, - defaults: defaults?.analytics, + defaults: { + consent: defaults?.consent ?? undefined, + ...defaults?.analytics, + }, }) this.personalization = new PersonalizationStateful(this.api, this.eventBuilder, { allowedEventTypes, + getAnonymousId, preventedComponentEvents, - defaults: defaults?.personalization, + defaults: { + consent: defaults?.consent ?? undefined, + ...defaults?.personalization, + }, }) } + /** + * Expose merged observable state for consumers. + */ get states(): CoreStates { return { ...this.analytics.states, @@ -67,11 +145,22 @@ class CoreStateful extends CoreBase implements ConsentController { } } - /** Do not reset consent, resetting personalization _currently_ also resets analytics' dependencies */ + /** + * Reset internal state. Consent is intentionally preserved. + * + * @remarks + * Resetting personalization also resets analytics dependencies as a + * consequence of the current shared-state design. + */ reset(): void { this.personalization.reset() } + /** + * Update consent state + * + * @param accept - `true` if the user has granted consent; `false` otherwise. + */ consent(accept: boolean): void { consent.value = accept } diff --git a/universal/core/src/CoreStateless.ts b/universal/core/src/CoreStateless.ts index b42b424d..c50ea35f 100644 --- a/universal/core/src/CoreStateless.ts +++ b/universal/core/src/CoreStateless.ts @@ -2,10 +2,30 @@ import { AnalyticsStateless } from './analytics' import CoreBase, { type CoreConfig } from './CoreBase' import { PersonalizationStateless } from './personalization' +/** + * Core runtime that constructs product instances for stateless environments. + * + * @public + */ class CoreStateless extends CoreBase { + /** Stateless analytics product. */ readonly analytics: AnalyticsStateless + /** Stateless personalization product. */ readonly personalization: PersonalizationStateless + /** + * Create a stateless core. Product instances share the same API client and + * event builder configured in {@link CoreBase}. + * + * @param config - Core configuration. + * @example + * ```ts + * const sdk = new CoreStateless({ clientId: 'app', environment: 'prod' }) + * core.analytics.trackFlagView({ componentId: 'hero' }) + * // or + * core.trackFlagView({ componentId: 'hero' }) + * ``` + */ constructor(config: CoreConfig) { super(config) diff --git a/universal/core/src/ProductBase.ts b/universal/core/src/ProductBase.ts index 8f0e0d0b..422f72fc 100644 --- a/universal/core/src/ProductBase.ts +++ b/universal/core/src/ProductBase.ts @@ -1,7 +1,6 @@ import type ApiClient from '@contentful/optimization-api-client' import type { InsightsEventType as AnalyticsEventType, - ComponentViewBuilderArgs, EventBuilder, OptimizationData, ExperienceEventType as PersonalizationEventType, @@ -9,31 +8,101 @@ import type { import { InterceptorManager } from './lib/interceptor' import ValuePresence from './lib/value-presence/ValuePresence' +/** + * Union of all event {@link AnalyticsEventType | type keys} that this package may emit. + * + * @public + */ export type EventType = AnalyticsEventType | PersonalizationEventType +/** + * Default allow‑list of event types that can be emitted without explicit consent. + * + * @internal + * @privateRemarks These defaults are only applied when a consumer does not provide + * {@link ProductConfig.allowedEventTypes}. + */ const defaultAllowedEvents: EventType[] = ['page', 'identify'] +/** + * Common configuration for all product implementations. + * + * @public + */ export interface ProductConfig { + /** + * The set of event type strings that are allowed to be sent even if consent is + * not granted. + * + * @defaultValue `['page', 'identify']` + * @remarks These types are compared against the `type` property of events. + */ allowedEventTypes?: EventType[] - preventedComponentEvents?: Record + + /** + * A map of duplication keys to a list of component IDs that should be + * considered duplicates and therefore suppressed. + * + * @remarks + * The actual duplication check is performed by {@link ValuePresence}. The + * keys of this record are used as duplication scopes. An empty string `''` + * is converted to an `indefined` scope when specific scopes are not required. + */ + preventedComponentEvents?: Record } +/** + * Lifecycle container for event and state interceptors. + * + * @internal + */ interface InterceptorLifecycle { + /** Interceptors invoked for individual events prior to validation/sending. */ event: InterceptorManager + /** Interceptors invoked before optimization state updates. */ state: InterceptorManager } +/** + * Shared base for all product implementations. + * + * @internal + * @remarks + * This abstract class is not exported as part of the public API surface. + * Concrete implementations (e.g., analytics) should extend this class and + * expose their own public methods. + */ abstract class ProductBase { + /** + * Allow‑list of event {@link AnalyticsEventType | type keys} permitted when consent is not present. + */ protected readonly allowedEventTypes?: string[] + + /** Event builder used to construct strongly‑typed events. */ protected readonly builder: EventBuilder + + /** Optimization API client used to send events to the Experience and Insights APIs. */ protected readonly api: ApiClient + + /** + * Deduplication helper used to track previously seen values within optional + * scopes + */ readonly duplicationDetector: ValuePresence + /** Interceptors that can mutate/augment outgoing events or optimization state. */ readonly interceptor: InterceptorLifecycle = { event: new InterceptorManager(), state: new InterceptorManager(), } + /** + * Creates a new product base instance. + * + * @param api - Optimization API client. + * @param builder - Event builder for constructing events. + * @param config - Optional configuration for allow‑lists and duplication prevention. + */ constructor(api: ApiClient, builder: EventBuilder, config?: ProductConfig) { this.api = api this.builder = builder diff --git a/universal/core/src/analytics/AnalyticsBase.ts b/universal/core/src/analytics/AnalyticsBase.ts index 1cb2759e..b6f551fe 100644 --- a/universal/core/src/analytics/AnalyticsBase.ts +++ b/universal/core/src/analytics/AnalyticsBase.ts @@ -1,9 +1,46 @@ -import type { InsightsEvent as AnalyticsEvent } from '@contentful/optimization-api-client' +import type { + InsightsEvent as AnalyticsEvent, + ComponentViewBuilderArgs, +} from '@contentful/optimization-api-client' import ProductBase from '../ProductBase' +/** + * Base class for analytics implementations (internal). + * + * @internal + * @remarks + * Concrete analytics classes should implement the component/flag view tracking + * methods below. This base is not part of the public API. + */ abstract class AnalyticsBase extends ProductBase { - abstract trackComponentView(...args: unknown[]): Promise | void - abstract trackFlagView(...args: unknown[]): Promise | void + /** + * Track a UI component view event. + * + * @param payload - Component view builder arguments. + * @param duplicationScope - Optional string used to scope duplication used in Stateful + * implementations. + * @privateRemarks + * Duplication prevention should be handled in Stateful implementations + */ + abstract trackComponentView( + payload: ComponentViewBuilderArgs, + duplicationScope?: string, + ): Promise | void + + /** + * Track a flag (feature) view event. + * + * @param payload - Flag view builder arguments. + * @param duplicationScope - Optional string used to scope duplication used in Stateful + * implementations. + * @returns A promise that resolves when processing is complete (or `void`). + * @privateRemarks + * Duplication prevention should be handled in Stateful implementations + */ + abstract trackFlagView( + payload: ComponentViewBuilderArgs, + duplicationScope?: string, + ): Promise | void } export default AnalyticsBase diff --git a/universal/core/src/analytics/AnalyticsStateful.ts b/universal/core/src/analytics/AnalyticsStateful.ts index 76c94653..e785c35e 100644 --- a/universal/core/src/analytics/AnalyticsStateful.ts +++ b/universal/core/src/analytics/AnalyticsStateful.ts @@ -22,28 +22,67 @@ import { } from '../signals' import AnalyticsBase from './AnalyticsBase' +/** + * Default analytics state values applied at construction time. + * + * @public + */ export interface AnalyticsProductConfigDefaults { + /** Whether analytics collection is allowed by default. */ consent?: boolean + /** Default profile to associate with events. */ profile?: Profile } +/** + * Configuration for the stateful analytics implementation. + * + * @public + */ export interface AnalyticsProductConfig extends ProductConfig { + /** + * Default signal values applied on initialization. + */ defaults?: AnalyticsProductConfigDefaults } +/** + * Observables exposed by the stateful analytics product. + * + * @public + */ export interface AnalyticsStates { + /** Observable stream of the active {@link Profile} (or `undefined`). */ profile: Observable } +/** + * Maximum number of queued events before an automatic flush is triggered. + */ const MAX_QUEUED_EVENTS = 25 +/** + * Analytics implementation that maintains local state (consent, profile) and + * queues events until flushed or the queue reaches a maximum size. + * + * @public + */ class AnalyticsStateful extends AnalyticsBase implements ConsentGuard { + /** In‑memory queue keyed by profile. */ private readonly queue = new Map() + /** Exposed observable state references. */ readonly states: AnalyticsStates = { profile: toObservable(profileSignal), } + /** + * Create a new stateful analytics instance. + * + * @param api - Optimization API client. + * @param builder - Event builder for constructing events. + * @param config - Product configuration and default state values. + */ constructor(api: ApiClient, builder: EventBuilder, config?: AnalyticsProductConfig) { super(api, builder, config) @@ -65,6 +104,9 @@ class AnalyticsStateful extends AnalyticsBase implements ConsentGuard { }) } + /** + * Reset analytics‑related signals and the last emitted event. + */ reset(): void { batch(() => { eventSignal.value = undefined @@ -72,52 +114,106 @@ class AnalyticsStateful extends AnalyticsBase implements ConsentGuard { }) } + /** + * Determine whether the named operation is permitted based on consent and + * allowed event type configuration. + * + * @param name - The method name; `'trackComponentView'` is normalized + * to `'component'` for allow‑list checks. + * @returns `true` if the operation is permitted; otherwise `false`. + */ hasConsent(name: string): boolean { - if (name === 'trackComponentView') name = 'component' - - return !!consent.value || (this.allowedEventTypes ?? []).includes(name) + return ( + !!consent.value || + (this.allowedEventTypes ?? []).includes( + name === 'trackComponentView' || name === 'trackFlagView' ? 'component' : name, + ) + ) } - onBlockedByConsent(name: string, args: unknown[]): void { + /** + * Hook invoked when an operation is blocked due to missing consent. + * + * @param name - The blocked operation name. + * @param payload - The original arguments supplied to the operation. + */ + onBlockedByConsent(name: string, payload: unknown[]): void { logger.warn( - `[Anaylytics] Event "${name}" was blocked due to lack of consent; args: ${JSON.stringify(args)}`, + `[Anaylytics] Event "${name}" was blocked due to lack of consent; payload: ${JSON.stringify(payload)}`, ) } - isNotDuplicated(_name: string, args: [ComponentViewBuilderArgs, string]): boolean { - const [{ componentId: value }, duplicationKey] = args + /** + * Guard used to suppress duplicate component/flag view events based on a + * duplication key and the component identifier. + * + * @param _name - The operation name (unused). + * @param payload - Tuple of [builderArgs, duplicationScope]. + * @returns `true` if the event is NOT a duplicate and should proceed. + */ + isNotDuplicated(_name: string, payload: [ComponentViewBuilderArgs, string]): boolean { + const [{ componentId: value }, duplicationScope] = payload - const isDuplicated = this.duplicationDetector.isPresent(duplicationKey, value) + const isDuplicated = this.duplicationDetector.isPresent(duplicationScope, value) - if (!isDuplicated) this.duplicationDetector.addValue(duplicationKey, value) + if (!isDuplicated) this.duplicationDetector.addValue(duplicationScope, value) return !isDuplicated } - onBlockedByDuplication(name: string, args: unknown[]): void { + /** + * Hook invoked when an operation is blocked by the duplication guard. + * + * @param name - The blocked operation name. + * @param payload - The original arguments supplied to the operation. + */ + onBlockedByDuplication(name: string, payload: unknown[]): void { const componentType = name === 'trackFlagView' ? 'flag' : 'component' logger.info( - `[Analytics] Duplicate "${componentType} view" event detected, skipping; args: ${JSON.stringify(args)}`, + `[Analytics] Duplicate "${componentType} view" event detected, skipping; payload: ${JSON.stringify(payload)}`, ) } + /** + * Queue a component view event for the active profile. + * + * @param payload - Component view builder arguments. + * @param _duplicationScope - Optional string used to scope duplication (used + * by guards); an empty string `''` is converted to the `undefined` scope + */ @guardedBy('isNotDuplicated', { onBlocked: 'onBlockedByDuplication' }) @guardedBy('hasConsent', { onBlocked: 'onBlockedByConsent' }) - async trackComponentView(payload: ComponentViewBuilderArgs, _duplicationKey = ''): Promise { + async trackComponentView( + payload: ComponentViewBuilderArgs, + _duplicationScope = '', + ): Promise { logger.info(`[Analytics] Processing "component view" event for`, payload.componentId) await this.enqueueEvent(this.builder.buildComponentView(payload)) } + /** + * Queue a flag view event for the active profile. + * + * @param payload - Flag view builder arguments. + * @param _duplicationScope - Optional string used to scope duplication (used + * by guards); an empty string `''` is converted to the `undefined` scope + */ @guardedBy('isNotDuplicated', { onBlocked: 'onBlockedByDuplication' }) @guardedBy('hasConsent', { onBlocked: 'onBlockedByConsent' }) - async trackFlagView(payload: ComponentViewBuilderArgs, _duplicationKey = ''): Promise { + async trackFlagView(payload: ComponentViewBuilderArgs, _duplicationScope = ''): Promise { logger.debug(`[Analytics] Processing "flag view" event for`, payload.componentId) await this.enqueueEvent(this.builder.buildFlagView(payload)) } + /** + * Intercept, validate, and place an event into the profile‑scoped queue; then + * trigger a size‑based flush if necessary. + * + * @param event - The event to enqueue. + */ private async enqueueEvent(event: InsightsEvent): Promise { const { value: profile } = profileSignal @@ -146,10 +242,19 @@ class AnalyticsStateful extends AnalyticsBase implements ConsentGuard { await this.flushMaxEvents() } + /** + * Flush the queue automatically when the total number of queued events + * reaches {@link MAX_QUEUED_EVENTS}. + */ private async flushMaxEvents(): Promise { if (this.queue.values().toArray().flat().length >= MAX_QUEUED_EVENTS) await this.flush() } + /** + * Send all queued events grouped by profile and clear the queue. + * @remarks Only under rare circumstances should there be more than one + * profile in a stateful application. + */ async flush(): Promise { logger.debug(`[Analytics] Flushing event queue`) @@ -157,6 +262,8 @@ class AnalyticsStateful extends AnalyticsBase implements ConsentGuard { this.queue.forEach((events, profile) => batches.push({ profile, events })) + if (!batches.length) return + await this.api.insights.sendBatchEvents(batches) this.queue.clear() diff --git a/universal/core/src/analytics/AnalyticsStateless.ts b/universal/core/src/analytics/AnalyticsStateless.ts index bb9d02b7..4b9b7d84 100644 --- a/universal/core/src/analytics/AnalyticsStateless.ts +++ b/universal/core/src/analytics/AnalyticsStateless.ts @@ -8,10 +8,31 @@ import { import { logger } from 'logger' import AnalyticsBase from './AnalyticsBase' +/** + * Arguments for tracking a component/flag view in stateless mode. + * + * @public + * @remarks + * The `profile` is optional; when omitted, the APIs may infer identity via + * other means. + */ +export type TrackViewArgs = ComponentViewBuilderArgs & { profile?: PartialProfile } + +/** + * Stateless analytics implementation that sends each event immediately in a + * single‑event batch. + * + * @public + */ class AnalyticsStateless extends AnalyticsBase { - async trackComponentView( - args: ComponentViewBuilderArgs & { profile?: PartialProfile }, - ): Promise { + /** + * Build, intercept, validate, and send a component view event. + * + * @param args - {@link TrackViewArgs} used to build the event. Includes an + * optional partial profile. + * @returns A promise that resolves once the batch has been sent. + */ + async trackComponentView(args: TrackViewArgs): Promise { logger.info(`[Analytics] Processing "component view" event`) const { profile, ...builderArgs } = args @@ -25,9 +46,14 @@ class AnalyticsStateless extends AnalyticsBase { await this.sendBatchEvent(parsed, profile) } - async trackFlagView( - args: ComponentViewBuilderArgs & { profile?: PartialProfile }, - ): Promise { + /** + * Build, intercept, validate, and send a flag view event. + * + * @param args - {@link TrackViewArgs} used to build the event. Includes an + * optional partial profile. + * @returns A promise that resolves once the batch has been sent. + */ + async trackFlagView(args: TrackViewArgs): Promise { logger.debug(`[Analytics] Processing "flag view" event`) const { profile, ...builderArgs } = args @@ -41,7 +67,15 @@ class AnalyticsStateless extends AnalyticsBase { await this.sendBatchEvent(parsed, profile) } - async sendBatchEvent(event: InsightsEvent, profile?: PartialProfile): Promise { + /** + * Send a single {@link InsightsEvent} wrapped in a one‑item batch. + * + * @param event - The event to send. + * @param profile - Optional partial profile to attach to the batch. + * @returns A promise that resolves when the API call completes. + * @internal + */ + private async sendBatchEvent(event: InsightsEvent, profile?: PartialProfile): Promise { const batchEvent: BatchInsightsEventArray = BatchInsightsEventArray.parse([ { profile, diff --git a/universal/core/src/lib/decorators/guardedBy.ts b/universal/core/src/lib/decorators/guardedBy.ts index 6e2216e7..fd6c1306 100644 --- a/universal/core/src/lib/decorators/guardedBy.ts +++ b/universal/core/src/lib/decorators/guardedBy.ts @@ -1,19 +1,84 @@ +/** + * A callback invoked when a method call is blocked by {@link guardedBy}. + * + * @param methodName - The name of the method that was attempted. + * @param args - The readonly array of arguments supplied to the blocked call. + * @returns Nothing. + * + * @public + */ type BlockHandler = (methodName: string, args: readonly unknown[]) => void +/** + * The original method implementation. + * + * @typeParam A - The parameter tuple of the original method. + * @typeParam R - The return type of the original method. + * @param value - The method being decorated. + * @param context - The Stage-3 decorator context for a class method. + * @returns Nothing. + * + * @remarks + * Users do not call this directly; it's returned by {@link guardedBy}. + */ +type GuardedByFunction = ( + value: (...args: A) => R, + context: ClassMethodDecoratorContext R>, +) => void + +/** + * Options that tweak the behavior of {@link guardedBy}. + * + * @typeParam T - The instance type on which the decorator is applied. + * + * @public + */ export interface GuardedByOptions { - /** If true, block when predicate is truthy; otherwise allow when truthy. */ + /** + * Inverts the predicate result. + * + * When `true`, a truthy predicate result **blocks** the method. + * When `false` (default) or omitted, a truthy predicate result **allows** the method. + * + * @defaultValue `false` + * @remarks + * This option is useful when the predicate expresses a *forbid* condition + * (e.g. "isLocked" or "isDestroyed") rather than an *allow* condition. + */ readonly invert?: boolean /** - * Either a function, or the name/symbol of an instance method to call when blocked. - * Both predicate and onBlocked are synchronous and receive (methodName, argsArray). + * Either a function to call when a method is blocked, or the name/symbol of + * an instance method on `this` to call when blocked. + * + * Both forms are **synchronous** and receive `(methodName, argsArray)`. + * If omitted, blocked calls fail silently (i.e., return `undefined` or + * `Promise` for async methods). + * + * @remarks + * - If a property key is supplied and the instance does not have a callable at that key, + * the hook is ignored. + * - The hook **must not** be `async`; any async work should be scheduled manually. */ readonly onBlocked?: BlockHandler | (keyof T & (string | symbol)) } +/** + * Type guard for functions. + * + * @internal + */ const isFunction = (v: unknown): v is (...args: readonly unknown[]) => unknown => typeof v === 'function' +/** + * Converts a property key to a readable string for logs and messages. + * + * @param name - The property key to stringify. + * @returns A human-friendly name. + * + * @internal + */ const nameToString = (name: PropertyKey): string => typeof name === 'string' ? name @@ -21,31 +86,86 @@ const nameToString = (name: PropertyKey): string => ? (name.description ?? String(name)) : String(name) -/** True when the onBlocked option is a property key (string or symbol). */ +/** + * True when the `onBlocked` option is a property key (string or symbol). + * + * @typeParam T - The instance type. + * @param v - The `onBlocked` option value. + * @returns Whether `v` is a property key. + * + * @internal + */ const isOnBlockedKey = ( v: GuardedByOptions['onBlocked'], ): v is keyof T & (string | symbol) => typeof v === 'string' || typeof v === 'symbol' -/** Detect declared `async` functions. */ +/** + * Detects declared `async` functions. + * + * @param fn - The candidate to test. + * @returns `true` if `fn` is an async function, else `false`. + * + * @internal + */ const isAsyncFunction = (fn: (...args: readonly unknown[]) => unknown): boolean => Object.prototype.toString.call(fn) === '[object AsyncFunction]' /** - * Methods-only decorator (blocks silently): - * - When blocked, runs onBlocked (if provided) and returns `undefined` - * — or `Promise` if the original method is async. - * - If the predicate is missing/misconfigured, throws TypeError. + * Decorator factory that **guards** class methods behind a synchronous predicate. + * + * When a decorated method is invoked: + * - If the predicate returns a value that evaluates to **allowed** (see `invert`), the original + * method is executed and its result is returned. + * - If the call is **blocked**, the optional `onBlocked` hook is invoked (if configured) and: + * - `undefined` is returned for sync methods; or + * - `Promise` is returned for async methods (to preserve `await` compatibility). * - * IMPORTANT: The returned decorator is generic over the concrete method type: - * (value: (...args: A) => R, context: ClassMethodDecoratorContext R>) => void + * @typeParam T - The instance type that owns both the predicate and the decorated method. + * + * @param predicateName - The name (string or symbol) of a **synchronous** instance method on `this` + * that acts as the predicate. It is called as `this[predicateName](methodName, argsArray)`. + * @param opts - Optional {@link GuardedByOptions | options} to configure inversion and `onBlocked`. + * + * @returns A methods-only class decorator compatible with Stage-3 decorators that wraps the method. + * + * @throws TypeError + * Thrown at initialization time (first instance construction) if `predicateName` does not resolve + * to a **synchronous function** on the instance. + * + * @remarks + * - This is a **methods-only** decorator; applying it to accessors/fields is a no-op. + * - The decorator preserves the original method's sync/async shape. + * - The predicate is invoked with `(decoratedMethodName, argsArray)` to support context-aware checks. + * + * @example + * Here, `canRun` allows the call when it returns truthy: + * ```ts + * class Runner { + * canRun(method: string, _args: readonly unknown[]) { return method !== 'stop'; } + * + * @guardedBy('canRun') + * go() { console.log('running'); } + * } + * ``` + * + * @example + * Invert the predicate and call a handler on block: + * ```ts + * class Door { + * isLocked() { return true } // truthy means "locked" + * onBlocked(method: string) { console.warn(`${method} blocked`) } + * + * @guardedBy('isLocked', { invert: true, onBlocked: 'onBlocked' }) + * open() { /* ... *\/ } + * } + * ``` + * + * @public */ export function guardedBy( predicateName: keyof T & (string | symbol), opts?: GuardedByOptions, -): ( - value: (...args: A) => R, - context: ClassMethodDecoratorContext R>, -) => void { +): GuardedByFunction { return function ( _value: (...args: A) => R, context: ClassMethodDecoratorContext R>, diff --git a/universal/core/src/lib/interceptor/InterceptorManager.ts b/universal/core/src/lib/interceptor/InterceptorManager.ts index 0bc0ca32..8d249b6c 100644 --- a/universal/core/src/lib/interceptor/InterceptorManager.ts +++ b/universal/core/src/lib/interceptor/InterceptorManager.ts @@ -1,21 +1,76 @@ +/** + * A utility type representing a value that may be synchronously available or + * produced asynchronously. + * + * @typeParam T - The resolved value type. + * @public + */ type MaybePromise = T | Promise /** - * A function that receives a value of type T and returns a (possibly async) value of the same type T. - * The input is readonly to discourage mutation of the original object. + * A function that receives a value of type `T` and returns a (possibly async) + * value of the same type `T`. The input is marked as `readonly` to discourage + * mutation of the original object. + * + * @typeParam T - The value type intercepted and returned. + * @param value - The current (readonly) value in the interception chain. + * @returns The next value for the chain, either directly or via a promise. + * @remarks Implementations SHOULD avoid mutating `value` and instead return a + * new or safely-updated instance. + * @see {@link InterceptorManager} + * @public */ export type Interceptor = (value: Readonly) => MaybePromise /** * Manages a list of interceptors and provides a way to run them in sequence. - * - Interceptors are executed in insertion order. + * + * Interceptors are executed in insertion order. Each interceptor receives the + * result of the previous interceptor (or the initial input for the first one) + * and may return a new value synchronously or asynchronously. + * + * @typeParam T - The value type processed by the interceptors. + * @remarks This class snapshots the current interceptor list at invocation time + * so additions/removals during `run` do not affect the in-flight execution. + * @example + * ```ts + * const mgr = new InterceptorManager(); + * const id = mgr.add((n) => n + 1); + * const final = await mgr.run(1); // 2 + * mgr.remove(id); + * ``` + * @public */ export class InterceptorManager { + /** + * The registry of interceptors keyed by their insertion id. + * + * @privateRemarks Internal storage; use {@link add}, {@link remove}, and + * {@link clear} to manage contents. + * @readonly + * @defaultValue `new Map()` + */ private readonly interceptors = new Map>() + + /** + * The next numeric id to assign to an added interceptor. + * + * @privateRemarks Used only to generate unique, monotonically increasing ids. + * @defaultValue `0` + */ private nextId = 0 /** - * Add an interceptor. Returns a numeric id that can be used to remove it later. + * Add an interceptor and return its identifier. + * + * @param interceptor - The interceptor function to register. + * @returns The numeric id that can later be used with {@link remove}. + * @remarks Interceptors are executed in the order they are added. + * @example + * ```ts + * const id = manager.add(async (value) => transform(value)); + * ``` + * @public */ add(interceptor: Interceptor): number { const { nextId: id } = this @@ -25,21 +80,44 @@ export class InterceptorManager { } /** - * Remove an interceptor by id. Returns true if one was removed. + * Remove an interceptor by its identifier. + * + * @param id - The id previously returned by {@link add}. + * @returns `true` if an interceptor was removed; otherwise `false`. + * @example + * ```ts + * const removed = manager.remove(id); + * ``` + * @public */ remove(id: number): boolean { return this.interceptors.delete(id) } /** - * Remove all interceptors. + * Remove all registered interceptors. + * + * @returns Nothing. + * @remarks After calling this, {@link count} will return `0`. + * @example + * ```ts + * manager.clear(); + * ``` + * @public */ clear(): void { this.interceptors.clear() } /** - * How many interceptors are registered. + * Get the number of currently registered interceptors. + * + * @returns The count of interceptors. + * @example + * ```ts + * if (manager.count() === 0) { /* ... *\/ } + * ``` + * @public */ count(): number { return this.interceptors.size @@ -47,7 +125,20 @@ export class InterceptorManager { /** * Run all interceptors in sequence on an input value and return the final result. - * Supports both sync and async interceptors; the return type is always Promise for consistency. + * + * Supports both sync and async interceptors; the return type is always `Promise` + * for consistency. + * + * @param input - The initial value to pass to the first interceptor. + * @returns A promise resolving to the final value after all interceptors have run. + * @throws May rethrow any error thrown by an interceptor. + * @remarks The interceptor list is snapshotted at invocation time; changes to + * the registry during execution do not affect the running sequence. + * @example + * ```ts + * const result = await manager.run(initial); + * ``` + * @public */ async run(input: T): Promise { // Snapshot to avoid issues if interceptors are added/removed during execution. diff --git a/universal/core/src/lib/value-presence/ValuePresence.ts b/universal/core/src/lib/value-presence/ValuePresence.ts index 37b554c2..06da7acf 100644 --- a/universal/core/src/lib/value-presence/ValuePresence.ts +++ b/universal/core/src/lib/value-presence/ValuePresence.ts @@ -1,8 +1,54 @@ +/** + * A scope identifier for grouping values. + * + * @remarks + * Use a non-empty string for a named scope. Use `undefined` for the + * "global/default" scope. An empty string (`""`) passed to the constructor + * initializer is normalized to `undefined`. + * + * @public + */ +type ValuePresenceScope = string | undefined + +/** + * Tracks whether a given value is present within one or more logical scopes. + * + * @remarks + * - Scope names are case-sensitive. + * - Presence is based on `Set.has` reference equality for objects and + * value equality for primitives. + * + * @example + * ```ts + * const presence = new ValuePresence({ default: ['a', 'b'] }) + * presence.isPresent('default', 'a') // true + * presence.addValue('default', 'c') + * presence.removeValue('default', 'b') + * presence.reset('default') + * ``` + * + * @see {@link ValuePresenceScope} + * @public + */ class ValuePresence { - readonly #map: Map> + /** + * Internal map of scope -> set of values present in that scope. + * + * @internal + */ + readonly #map: Map> + /** + * Create a new {@link ValuePresence}. + * + * @param defaultMap - Optional initial data. Keys are scope names; values are arrays of items to seed. + * Empty-string keys are normalized to the default scope (`undefined`). + * + * @remarks + * - If `defaultMap` contains duplicate items for a scope, duplicates are collapsed by the `Set`. + */ constructor(defaultMap?: Record) { - const map = new Map>() + const map = new Map>() if (defaultMap) Object.entries(defaultMap).map(([scope, values]) => @@ -12,11 +58,45 @@ class ValuePresence { this.#map = map } - isPresent(scope: string | undefined, value: unknown): boolean { + /** + * Check whether a value is present within a given scope. + * + * @param scope - The scope to check. Use `undefined` for the default scope. + * @param value - The value to test for presence. + * @returns `true` if the value is present in the specified scope; otherwise `false`. + * + * @remarks + * Presence testing uses `Set.prototype.has` semantics. + * + * @example + * ```ts + * presence.isPresent(undefined, 42) // e.g., true or false + * ``` + * + * @public + */ + isPresent(scope: ValuePresenceScope, value: unknown): boolean { return this.#map.get(scope)?.has(value) ?? false } - addValue(scope: string | undefined, value: unknown): void { + /** + * Add a value to a scope, creating the scope if it does not exist. + * + * @param scope - Scope to add the value to. Use `undefined` for the default scope. + * @param value - The value to add. + * @returns void + * + * @remarks + * - No-op if the value is already present (due to `Set` semantics). + * + * @example + * ```ts + * presence.addValue('users', userId) + * ``` + * + * @public + */ + addValue(scope: ValuePresenceScope, value: unknown): void { const values = this.#map.get(scope) if (!values) { @@ -26,11 +106,50 @@ class ValuePresence { } } - removeValue(scope: string | undefined, value: unknown): void { + /** + * Remove a value from a scope. + * + * @param scope - Scope to remove from. Use `undefined` for the default scope. + * @param value - The value to remove. + * @returns void + * + * @remarks + * If the scope does not exist or the value is not present, this is a no-op. + * + * @example + * ```ts + * presence.removeValue('users', userId) + * ``` + * + * @public + */ + removeValue(scope: ValuePresenceScope, value: unknown): void { this.#map.get(scope)?.delete(value) } - reset(scope?: string): void { + /** + * Clear values from a single scope, or from all scopes. + * + * @param scope - If provided, clears only that scope. If omitted, clears all scopes. + * @returns void + * + * @remarks + * - When called with a specific scope that does not exist, this is a no-op. + * - When called with no arguments, all scopes and values are removed. + * - Clearing a non-existent scope will not create the scope. + * + * @example + * ```ts + * // Clear one scope + * presence.reset('users') + * + * // Clear all scopes + * presence.reset() + * ``` + * + * @public + */ + reset(scope?: ValuePresenceScope): void { if (scope !== undefined) { this.#map.get(scope)?.clear() } else { diff --git a/universal/core/src/personalization/PersonalizationBase.ts b/universal/core/src/personalization/PersonalizationBase.ts index 3d2fab57..9a8e2686 100644 --- a/universal/core/src/personalization/PersonalizationBase.ts +++ b/universal/core/src/personalization/PersonalizationBase.ts @@ -1,19 +1,200 @@ import type { + ChangeArray, + ComponentViewBuilderArgs, + IdentifyBuilderArgs, + Json, + MergeTagEntry, OptimizationData, + PageViewBuilderArgs, ExperienceEvent as PersonalizationEvent, + Profile, + SelectedPersonalizationArray, + TrackBuilderArgs, } from '@contentful/optimization-api-client' +import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful' import ProductBase from '../ProductBase' -import { FlagsResolver, MergeTagValueResolver, PersonalizedEntryResolver } from './resolvers' +import { + FlagsResolver, + MergeTagValueResolver, + PersonalizedEntryResolver, + type ResolvedData, +} from './resolvers' -abstract class PersonalizationBase extends ProductBase { +/** + * These methods assist in resolving values via Resolvers + * + * @internal + * @privateRemarks + * This interface exists to document that the included methods should not be + * considered static. + */ +interface ResolverMethods { + /** + * Get the specified Custom Flag's value from the supplied changes. + * @param name - The name or key of the Custom Flag. + * @param changes - Optional changes array. + * @returns The current value of the Custom Flag if found. + * @remarks + * The changes array can be sourced from the data returned when emitting any + * personalization event. + * */ + getCustomFlag: (name: string, changes?: ChangeArray) => Json + + /** + * Resolve a Contentful entry to a personalized variant using the current + * or provided selected personalizations. + * + * @typeParam S - Entry skeleton type. + * @typeParam M - Chain modifiers. + * @typeParam L - Locale code. + * @param entry - The entry to personalize. + * @param personalizations - Optional selections. + * @returns The resolved entry data. + * @remarks + * Selected personalizations can be sourced from the data returned when emitting any + * personalization event. + */ + personalizeEntry: ( + entry: Entry, + personalizations?: SelectedPersonalizationArray, + ) => ResolvedData + + /** + * Resolve a merge tag to a value based on the current (or provided) profile. + * + * @param embeddedEntryNodeTarget - The merge‑tag entry node to resolve. + * @param profile - Optional profile. + * @returns The resolved value (type depends on the tag). + * @remarks + * Merge tags are references to profile data that can be substituted into content. The + * profile can be sourced from the data returned when emitting any personalization event. + */ + getMergeTagValue: (embeddedEntryNodeTarget: MergeTagEntry, profile?: Profile) => unknown +} + +/** + * Internal base for personalization products. + * + * @internal + * @remarks + * Concrete implementations should extend this class to expose public methods for + * identify, page, and track events. This base wires in shared singleton + * resolvers used to fetch/resolve personalized data. + */ +abstract class PersonalizationBase + extends ProductBase + implements ResolverMethods +{ + /** + * Static {@link FlagsResolver | resolver} for evaluating personalized + * custom flags. + */ readonly flagsResolver = FlagsResolver + + /** + * Static {@link MergeTagValueResolver | resolver} that returns values + * sourced from a user profile based on a Contentful Merge Tag entry. + */ readonly mergeTagValueResolver = MergeTagValueResolver + + /** + * Static {@link PersonalizedEntryResolver | resolver } for personalized + * Contentful entries (e.g., entry variants targeted to a profile audience). + * + * @remarks + * Used by higher-level personalization flows to materialize entry content + * prior to event emission. + */ readonly personalizedEntryResolver = PersonalizedEntryResolver - abstract identify(...args: unknown[]): Promise - abstract page(...args: unknown[]): Promise - abstract track(...args: unknown[]): Promise - abstract trackComponentView(...args: unknown[]): Promise + /** + * Get the specified Custom Flag's value from the supplied changes. + * @param name - The name/key of the Custom Flag. + * @param changes - Optional changes array. + * @returns The current value of the Custom Flag if found. + * @remarks + * The changes array can be sourced from the data returned when emitting any + * personalization event. + * */ + getCustomFlag(name: string, changes?: ChangeArray): Json { + return FlagsResolver.resolve(changes)[name] + } + + /** + * Resolve a Contentful entry to a personalized variant using the current + * or provided selected personalizations. + * + * @typeParam S - Entry skeleton type. + * @typeParam M - Chain modifiers. + * @typeParam L - Locale code. + * @param entry - The entry to personalize. + * @param personalizations - Optional selected personalizations. + * @returns The resolved entry data. + * @remarks + * Selected personalizations can be sourced from the data returned when emitting any + * personalization event. + */ + personalizeEntry< + S extends EntrySkeletonType, + M extends ChainModifiers = ChainModifiers, + L extends LocaleCode = LocaleCode, + >(entry: Entry, personalizations?: SelectedPersonalizationArray): ResolvedData { + return PersonalizedEntryResolver.resolve(entry, personalizations) + } + + /** + * Resolve a merge tag to a value based on the current (or provided) profile. + * + * @param embeddedEntryNodeTarget - The merge tag entry node to resolve. + * @param profile - Optional profile. + * @returns The resolved value (type depends on the tag). + * @remarks + * Merge tags are references to profile data that can be substituted into content. The + * profile can be sourced from the data returned when emitting any personalization event. + */ + getMergeTagValue(embeddedEntryNodeTarget: MergeTagEntry, profile?: Profile): unknown { + return MergeTagValueResolver.resolve(embeddedEntryNodeTarget, profile) + } + + /** + * Identify the current profile/visitor to associate traits with a profile. + * + * @param payload - Identify builder payload. + * @returns The resulting {@link OptimizationData} for the identified user. + */ + abstract identify(payload: IdentifyBuilderArgs): Promise + + /** + * Record a page view. + * + * @param payload - Page view builder payload. + * @returns The evaluated {@link OptimizationData} for this page view. + */ + abstract page(payload: PageViewBuilderArgs): Promise + + /** + * Record a custom track event. + * + * @param payload - Track builder payload. + * @returns The evaluated {@link OptimizationData} for this event. + */ + abstract track(payload: TrackBuilderArgs): Promise + + /** + * Record a "sticky" component view. + * + * @param payload - "Sticky" component view builder payload. + * @returns The evaluated {@link OptimizationData} for this component view. + * @remarks + * This method is intended to be called only when a component is considered + * "sticky". + * @privateRemarks + * Duplication prevention should be handled in Stateful implementations. + */ + abstract trackComponentView( + payload: ComponentViewBuilderArgs, + duplicationScope?: string, + ): Promise } export default PersonalizationBase diff --git a/universal/core/src/personalization/PersonalizationStateful.ts b/universal/core/src/personalization/PersonalizationStateful.ts index 68201488..741724e6 100644 --- a/universal/core/src/personalization/PersonalizationStateful.ts +++ b/universal/core/src/personalization/PersonalizationStateful.ts @@ -7,6 +7,7 @@ import { type Flags, type IdentifyBuilderArgs, IdentifyEvent, + type Json, type MergeTagEntry, type OptimizationData, type PageViewBuilderArgs, @@ -29,46 +30,104 @@ import { consent, effect, event as eventSignal, - flags, flags as flagsSignal, type Observable, - personalizations, personalizations as personalizationsSignal, - profile, profile as profileSignal, toObservable, } from '../signals' import PersonalizationBase from './PersonalizationBase' -import { MergeTagValueResolver, PersonalizedEntryResolver, type ResolvedData } from './resolvers' +import type { ResolvedData } from './resolvers' +/** + * Default state values for {@link PersonalizationStateful} applied at construction time. + * + * @public + */ export interface PersonalizationProductConfigDefaults { + /** Whether personalization is allowed by default. */ consent?: boolean + /** Initial diff of changes produced by the service. */ changes?: ChangeArray + /** Default active profile used for personalization. */ profile?: Profile + /** Preselected personalization variants (e.g., winning treatments). */ personalizations?: SelectedPersonalizationArray } +/** + * Configuration for {@link PersonalizationStateful}. + * + * @public + */ export interface PersonalizationProductConfig extends ProductConfig { + /** Default signal values applied during initialization. */ defaults?: PersonalizationProductConfigDefaults + + /** + * Function used to obtain an anonymous user identifier. + * + * @remarks + * If a `getAnonymousId` function has been provided, the returned value will + * take precedence over the `id` property of the current {@link Profile} + * signal value + * + * @returns A string identifier, or `undefined` if no anonymous ID is available. + */ + getAnonymousId?: () => string | undefined } +/** + * Observables exposed by {@link PersonalizationStateful} that mirror internal signals. + * + * @public + */ export interface PersonalizationStates { + /** Live view of effective flags for the current profile (if available). */ flags: Observable + /** Live view of the current profile. */ profile: Observable + /** Live view of selected personalizations (variants). */ personalizations: Observable } -class Personalization extends PersonalizationBase implements ConsentGuard { +/** + * Stateful personalization product that manages consent, profile, flags, and + * selected variants while emitting Experience events and updating state. + * + * @public + * @remarks + * The class maintains reactive signals and exposes read‑only observables via + * {@link PersonalizationStateful.states}. Events are validated via schema parsers and + * run through interceptors before being submitted. Resulting state is merged + * back into signals. + */ +class PersonalizationStateful extends PersonalizationBase implements ConsentGuard { + /** Exposed observable state references. */ readonly states: PersonalizationStates = { - flags: toObservable(flags), - profile: toObservable(profile), - personalizations: toObservable(personalizations), + flags: toObservable(flagsSignal), + profile: toObservable(profileSignal), + personalizations: toObservable(personalizationsSignal), } + /** + * Function that provides an anonymous ID when available. + * + * @internal + */ + getAnonymousId: () => string | undefined + + /** + * Create a new stateful personalization instance. + * + * @param api - Optimization API client used to upsert profiles. + * @param builder - Event builder for constructing experience events. + * @param config - Optional configuration, including default state values. + */ constructor(api: ApiClient, builder: EventBuilder, config?: PersonalizationProductConfig) { super(api, builder, config) - const { defaults } = config ?? {} + const { defaults, getAnonymousId } = config ?? {} if (defaults) { const { @@ -89,6 +148,9 @@ class Personalization extends PersonalizationBase implements ConsentGuard { consent.value = defaultConsent } + this.getAnonymousId = getAnonymousId ?? (() => undefined) + + // Log signal changes for observability effect(() => { logger.info( `[Personalization] Profile ${profileSignal.value && `with ID ${profileSignal.value.id}`} has been ${profileSignal.value ? 'set' : 'cleared'}`, @@ -108,10 +170,12 @@ class Personalization extends PersonalizationBase implements ConsentGuard { }) } - get flags(): Flags | undefined { - return flagsSignal.value - } - + /** + * Reset stateful signals managed by this product. + * + * @remarks + * Clears `changes`, `profile`, and selected `personalizations`. + */ reset(): void { batch(() => { changesSignal.value = undefined @@ -120,6 +184,26 @@ class Personalization extends PersonalizationBase implements ConsentGuard { }) } + /** + * Get the specified Custom Flag's value (derived from the signal). + * @param name - The name or key of the Custom Flag. + * @returns The current value of the Custom Flag if found. + * */ + getCustomFlag(name: string, changes: ChangeArray | undefined = changesSignal.value): Json { + return super.getCustomFlag(name, changes) + } + + /** + * Resolve a Contentful entry to a personalized variant using the current + * or provided selections. + * + * @typeParam S - Entry skeleton type. + * @typeParam M - Chain modifiers. + * @typeParam L - Locale code. + * @param entry - The entry to personalize. + * @param personalizations - Optional selections; defaults to the current signal value. + * @returns The resolved entry data. + */ personalizeEntry< S extends EntrySkeletonType, M extends ChainModifiers = ChainModifiers, @@ -128,46 +212,93 @@ class Personalization extends PersonalizationBase implements ConsentGuard { entry: Entry, personalizations: SelectedPersonalizationArray | undefined = personalizationsSignal.value, ): ResolvedData { - return PersonalizedEntryResolver.resolve(entry, personalizations) + return super.personalizeEntry(entry, personalizations) } + /** + * Resolve a merge tag to a value based on the current (or provided) profile. + * + * @param embeddedEntryNodeTarget - The merge‑tag entry node to resolve. + * @param profile - Optional profile; defaults to the current signal value. + * @returns The resolved value (type depends on the tag). + * @remarks + * Merge tags are references to profile data that can be substituted into content. + */ getMergeTagValue( embeddedEntryNodeTarget: MergeTagEntry, profile: Profile | undefined = profileSignal.value, ): unknown { - return MergeTagValueResolver.resolve(embeddedEntryNodeTarget, profile) + return super.getMergeTagValue(embeddedEntryNodeTarget, profile) } + /** + * Determine whether the named operation is permitted based on consent and + * allowed event type configuration. + * + * @param name - Method name; `trackComponentView` is normalized to + * `'component'` for allow‑list checks. + * @returns `true` if the operation is permitted; otherwise `false`. + */ hasConsent(name: string): boolean { - if (name === 'trackComponentView') name = 'component' - - return !!consent.value || (this.allowedEventTypes ?? []).includes(name) + return ( + !!consent.value || + (this.allowedEventTypes ?? []).includes( + name === 'trackComponentView' || name === 'trackFlagView' ? 'component' : name, + ) + ) } - onBlockedByConsent(name: string, args: unknown[]): void { + /** + * Hook invoked when an operation is blocked due to missing consent. + * + * @param name - The blocked operation name. + * @param payload - The original arguments supplied to the operation. + */ + onBlockedByConsent(name: string, payload: unknown[]): void { logger.warn( - `[Personalization] Event "${name}" was blocked due to lack of consent; args: ${JSON.stringify(args)}`, + `[Personalization] Event "${name}" was blocked due to lack of consent; payload: ${JSON.stringify(payload)}`, ) } - isNotDuplicated(_name: string, args: [ComponentViewBuilderArgs, string]): boolean { - const [{ componentId: value }, duplicationKey] = args + /** + * Guard used to suppress duplicate component view events for the same + * component based on a duplication key and the component identifier. + * + * @param _name - Operation name (unused). + * @param payload - Tuple `[builderArgs, duplicationScope]`. + * @returns `true` if the event is NOT a duplicate and should proceed. + */ + isNotDuplicated(_name: string, payload: [ComponentViewBuilderArgs, string]): boolean { + const [{ componentId: value }, duplicationScope] = payload - const isDuplicated = this.duplicationDetector.isPresent(duplicationKey, value) + const isDuplicated = this.duplicationDetector.isPresent(duplicationScope, value) - if (!isDuplicated) this.duplicationDetector.addValue(duplicationKey, value) + if (!isDuplicated) this.duplicationDetector.addValue(duplicationScope, value) return !isDuplicated } - onBlockedByDuplication(_name: string, args: unknown[]): void { + /** + * Hook invoked when an operation is blocked by the duplication guard. + * + * @param _name - The blocked operation name (unused). + * @param payload - The original arguments supplied to the operation. + */ + onBlockedByDuplication(_name: string, payload: unknown[]): void { logger.info( - `[Analytics] Duplicate "component view" event detected, skipping; args: ${JSON.stringify(args)}`, + `[Analytics] Duplicate "component view" event detected, skipping; payload: ${JSON.stringify(payload)}`, ) } + /** + * Identify the current profile/visitor to associate traits with a profile + * and update optimization state. + * + * @param payload - Identify builder payload. + * @returns The resulting {@link OptimizationData} for the identified user. + */ @guardedBy('hasConsent', { onBlocked: 'onBlockedByConsent' }) - async identify(payload: IdentifyBuilderArgs): Promise { + async identify(payload: IdentifyBuilderArgs): Promise { logger.info('[Personalization] Sending "identify" event') const event = IdentifyEvent.parse(this.builder.buildIdentify(payload)) @@ -175,6 +306,12 @@ class Personalization extends PersonalizationBase implements ConsentGuard { return await this.upsertProfile(event) } + /** + * Record a page view and update optimization state. + * + * @param payload - Page view builder payload. + * @returns The evaluated {@link OptimizationData} for this page view. + */ @guardedBy('hasConsent', { onBlocked: 'onBlockedByConsent' }) async page(payload: PageViewBuilderArgs): Promise { logger.info('[Personalization] Sending "page" event') @@ -184,6 +321,12 @@ class Personalization extends PersonalizationBase implements ConsentGuard { return await this.upsertProfile(event) } + /** + * Record a custom track event and update optimization state. + * + * @param payload - Track builder payload. + * @returns The evaluated {@link OptimizationData} for this event. + */ @guardedBy('hasConsent', { onBlocked: 'onBlockedByConsent' }) async track(payload: TrackBuilderArgs): Promise { logger.info(`[Personalization] Sending "track" event "${payload.event}"`) @@ -193,12 +336,18 @@ class Personalization extends PersonalizationBase implements ConsentGuard { return await this.upsertProfile(event) } - /** AKA sticky component view */ @guardedBy('isNotDuplicated', { onBlocked: 'onBlockedByDuplication' }) @guardedBy('hasConsent', { onBlocked: 'onBlockedByConsent' }) + /** + * Record a "sticky" component view and update optimization state. + * + * @param payload - Component view builder payload. + * @param _duplicationScope - Optional duplication scope key used to suppress duplicates. + * @returns The evaluated {@link OptimizationData} for this component view. + */ async trackComponentView( payload: ComponentViewBuilderArgs, - _duplicationKey = '', + _duplicationScope = '', ): Promise { logger.info(`[Personalization] Sending "track personalization" event for`, payload.componentId) @@ -207,12 +356,24 @@ class Personalization extends PersonalizationBase implements ConsentGuard { return await this.upsertProfile(event) } + /** + * Intercept and submit a single event to the Experience API, updating output + * signals with the returned state. + * + * @param event - The event to submit. + * @returns The {@link OptimizationData} returned by the service. + * @internal + * @privateRemarks + * If a `getAnonymousId` function has been provided, the returned value will + * take precedence over the `id` property of the current {@link Profile} + * signal value + */ private async upsertProfile(event: PersonalizationEvent): Promise { const intercepted = await this.interceptor.event.run(event) eventSignal.value = intercepted - const anonymousId = this.builder.getAnonymousId() + const anonymousId = this.getAnonymousId() if (anonymousId) logger.info('[Personalization] Anonymous ID found:', anonymousId) const data = await this.api.experience.upsertProfile({ @@ -225,6 +386,12 @@ class Personalization extends PersonalizationBase implements ConsentGuard { return data } + /** + * Apply returned optimization state to local signals after interceptor processing. + * + * @param data - Optimization state returned by the service. + * @internal + */ private async updateOutputSignals(data: OptimizationData): Promise { const intercepted = await this.interceptor.state.run(data) @@ -239,4 +406,4 @@ class Personalization extends PersonalizationBase implements ConsentGuard { } } -export default Personalization +export default PersonalizationStateful diff --git a/universal/core/src/personalization/PersonalizationStateless.ts b/universal/core/src/personalization/PersonalizationStateless.ts index 333df210..05cd9a8b 100644 --- a/universal/core/src/personalization/PersonalizationStateless.ts +++ b/universal/core/src/personalization/PersonalizationStateless.ts @@ -14,62 +14,107 @@ import { import { logger } from 'logger' import PersonalizationBase from './PersonalizationBase' +/** + * Stateless personalization implementation that immediately validates and sends + * a single event to the Experience API, upserting the profile as needed. + * + * @public + * @remarks + * Each public method constructs a strongly-typed event via the shared builder, + * runs it through event interceptors, and performs a profile upsert using the + * Experience API. If an anonymous ID is available from the builder, it will be + * preferred as the `profileId` unless an explicit profile is provided. + */ class PersonalizationStateless extends PersonalizationBase { + /** + * Identify the current profile/visitor to associate traits with a profile. + * + * @param payload - Identify builder arguments with an optional partial + * profile to attach to the upsert request. + * @returns The resulting {@link OptimizationData} for the identified user. + */ async identify( - args: IdentifyBuilderArgs & { profile?: PartialProfile }, - ): Promise { + payload: IdentifyBuilderArgs & { profile?: PartialProfile }, + ): Promise { logger.info('[Personalization] Sending "identify" event') - const { profile, ...builderArgs } = args + const { profile, ...builderArgs } = payload const event = IdentifyEvent.parse(this.builder.buildIdentify(builderArgs)) return await this.upsertProfile(event, profile) } - async page(args: PageViewBuilderArgs & { profile?: PartialProfile }): Promise { + /** + * Record a page view. + * + * @param payload - Page view builder arguments with an optional partial profile. + * @returns The evaluated {@link OptimizationData} for this page view. + */ + async page( + payload: PageViewBuilderArgs & { profile?: PartialProfile }, + ): Promise { logger.info('[Personalization] Sending "page" event') - const { profile, ...builderArgs } = args + const { profile, ...builderArgs } = payload const event = PageViewEvent.parse(this.builder.buildPageView(builderArgs)) return await this.upsertProfile(event, profile) } - async track(args: TrackBuilderArgs & { profile?: PartialProfile }): Promise { - logger.info(`[Personalization] Sending "track" event "${args.event}"`) + /** + * Record a custom track event. + * + * @param payload - Track builder arguments with an optional partial profile. + * @returns The evaluated {@link OptimizationData} for this event. + */ + async track(payload: TrackBuilderArgs & { profile?: PartialProfile }): Promise { + logger.info(`[Personalization] Sending "track" event "${payload.event}"`) - const { profile, ...builderArgs } = args + const { profile, ...builderArgs } = payload const event = TrackEvent.parse(this.builder.buildTrack(builderArgs)) return await this.upsertProfile(event, profile) } + /** + * Record a "sticky" component view. + * + * @param payload - Component view builder arguments with an optional partial profile. + * @returns The evaluated {@link OptimizationData} for this component view. + */ async trackComponentView( - args: ComponentViewBuilderArgs & { profile?: PartialProfile }, + payload: ComponentViewBuilderArgs & { profile?: PartialProfile }, ): Promise { logger.info(`[Personalization] Sending "track personalization" event`) - const { profile, ...builderArgs } = args + const { profile, ...builderArgs } = payload const event = ComponentViewEvent.parse(this.builder.buildComponentView(builderArgs)) return await this.upsertProfile(event, profile) } + /** + * Intercept, validate, and upsert the profile with a single personalization + * event. + * + * @param event - The {@link PersonalizationEvent} to submit. + * @param profile - Optional partial profile. If omitted, the anonymous ID from + * the builder (when present) is used as the `profileId`. + * @returns The {@link OptimizationData} returned by the Experience API. + * @internal + */ private async upsertProfile( event: PersonalizationEvent, profile?: PartialProfile, ): Promise { const intercepted = await this.interceptor.event.run(event) - const anonymousId = this.builder.getAnonymousId() - if (anonymousId) logger.info('[Personalization] Anonymous ID found:', anonymousId) - const data = await this.api.experience.upsertProfile({ - profileId: anonymousId ?? profile?.id, + profileId: profile?.id, events: [intercepted], }) diff --git a/universal/core/src/personalization/resolvers/FlagsResolver.ts b/universal/core/src/personalization/resolvers/FlagsResolver.ts index dec51561..96c6ce5f 100644 --- a/universal/core/src/personalization/resolvers/FlagsResolver.ts +++ b/universal/core/src/personalization/resolvers/FlagsResolver.ts @@ -1,26 +1,51 @@ import type { ChangeArray, Flags } from '@contentful/optimization-api-client' +/** + * Resolves a {@link Flags} map from a list of optimization changes. + * + * @public + * @remarks + * Given an Optimization {@link ChangeArray}, this utility flattens the list into a + * simple key–value object suitable for quick lookups in client code. When `changes` + * is `undefined`, an empty object is returned. If a change value is wrapped in an + * object like `{ value: { ... } }`, this resolver unwraps it to the underlying object. + */ const FlagsResolver = { + /** + * Build a flattened map of flag keys to values from a change list. + * + * @param changes - The change list returned by the optimization service. + * @returns A map of flag keys to their resolved values. + * @example + * ```ts + * const flags = FlagsResolver.resolve(data.changes) + * if (flags['theme'] === 'dark') enableDarkMode() + * ``` + * @example + * // Handles wrapped values produced by the API + * ```ts + * const flags = FlagsResolver.resolve([ + * { type: 'Variable', key: 'price', value: { value: { amount: 10, currency: 'USD' } } } + * ]) + * console.log(flags.price.amount) // 10 + * ``` + */ resolve(changes?: ChangeArray): Flags { if (!changes) return {} - return ( - changes - // .filter((change): change is VariableChange => change.type === 'Variable') - .reduce((acc, { key, value }) => { - const actualValue = - typeof value === 'object' && - value !== null && - 'value' in value && - typeof value.value === 'object' - ? value.value - : value + return changes.reduce((acc, { key, value }) => { + const actualValue = + typeof value === 'object' && + value !== null && + 'value' in value && + typeof value.value === 'object' + ? value.value + : value - acc[key] = actualValue + acc[key] = actualValue - return acc - }, {}) - ) + return acc + }, {}) }, } diff --git a/universal/core/src/personalization/resolvers/MergeTagValueResolver.ts b/universal/core/src/personalization/resolvers/MergeTagValueResolver.ts index 83aa4272..6b8bcef8 100644 --- a/universal/core/src/personalization/resolvers/MergeTagValueResolver.ts +++ b/universal/core/src/personalization/resolvers/MergeTagValueResolver.ts @@ -2,13 +2,51 @@ import { MergeTagEntry, Profile } from '@contentful/optimization-api-client' import { get } from 'es-toolkit/compat' import { logger } from 'logger' +/** Base string for log messages when merge-tag resolution fails. */ const RESOLUTION_WARNING_BASE = '[Personalization] Could not resolve Merge Tag value:' +/** + * Resolves merge tag values from a {@link Profile}. + * + * @public + * @remarks + * *Merge tags* are references to user profile data that may be embedded in content + * and expanded at runtime. This resolver normalizes the merge-tag identifier into + * a set of candidate selectors and searches the profile for a matching value. + * Result values are returned as strings; numeric/boolean primitives are stringified. + */ const MergeTagValueResolver = { + /** + * Type guard to ensure the input is a {@link MergeTagEntry}. + * + * @param embeddedEntryNodeTarget - Unknown value to validate. + * @returns `true` if the input is a valid merge-tag entry. + * @example + * ```ts + * if (MergeTagValueResolver.isMergeTagEntry(node)) { + * // safe to read fields + * } + * ``` + */ isMergeTagEntry(embeddedEntryNodeTarget: unknown): embeddedEntryNodeTarget is MergeTagEntry { return MergeTagEntry.safeParse(embeddedEntryNodeTarget).success }, + /** + * Generate a list of candidate selectors for a merge-tag ID. + * + * @param id - Merge-tag identifier (segments separated by `_`). + * @returns Array of dot-path selectors to try against a profile. + * @example + * ```ts + * // "profile_name_first" -> [ + * // 'profile', + * // 'profile.name', + * // 'profile.name.first' + * // ] + * const selectors = MergeTagValueResolver.normalizeSelectors('profile_name_first') + * ``` + */ normalizeSelectors(id: string): string[] { return id.split('_').map((_path, index, paths) => { const dotPath = paths.slice(0, index).join('.') @@ -18,6 +56,20 @@ const MergeTagValueResolver = { }) }, + /** + * Look up a merge-tag value from a profile using normalized selectors. + * + * @param id - Merge-tag identifier. + * @param profile - Profile from which to resolve the value. + * @returns A stringified primitive if found; otherwise `undefined`. + * @example + * ```ts + * const value = MergeTagValueResolver.getValueFromProfile('user_email', profile) + * if (value) sendEmailTo(value) + * ``` + * @remarks + * Only string/number/boolean primitives are returned; objects/arrays are ignored. + */ getValueFromProfile(id: string, profile?: Profile): string | undefined { const selectors = MergeTagValueResolver.normalizeSelectors(id) const matchingSelector = selectors.find((selector) => get(profile, selector)) @@ -35,6 +87,20 @@ const MergeTagValueResolver = { return `${value}` }, + /** + * Resolve the display value for a merge-tag entry using the provided profile, + * falling back to the entry's configured `nt_fallback` when necessary. + * + * @param mergeTagEntry - The merge-tag entry to resolve. + * @param profile - Optional profile used for lookup. + * @returns The resolved string, or `undefined` if the entry is invalid and no + * fallback is available. + * @example + * ```ts + * const text = MergeTagValueResolver.resolve(entry, profile) + * render(text ?? 'Guest') + * ``` + */ resolve(mergeTagEntry: MergeTagEntry | undefined, profile?: Profile): string | undefined { if (!MergeTagValueResolver.isMergeTagEntry(mergeTagEntry)) { logger.warn(RESOLUTION_WARNING_BASE, 'supplied entry is not a Merge Tag entry') diff --git a/universal/core/src/personalization/resolvers/PersonalizedEntryResolver.ts b/universal/core/src/personalization/resolvers/PersonalizedEntryResolver.ts index 434dbdcb..fa800430 100644 --- a/universal/core/src/personalization/resolvers/PersonalizedEntryResolver.ts +++ b/universal/core/src/personalization/resolvers/PersonalizedEntryResolver.ts @@ -14,18 +14,60 @@ import { import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from 'contentful' import { logger } from 'logger' +/** + * Result returned by {@link PersonalizedEntryResolver.resolve}. + * + * @typeParam S - Entry skeleton type. + * @typeParam M - Chain modifiers. + * @typeParam L - Locale code. + * @public + */ export interface ResolvedData< S extends EntrySkeletonType, M extends ChainModifiers = ChainModifiers, L extends LocaleCode = LocaleCode, > { + /** The baseline or resolved variant entry. */ entry: Entry + /** The selected personalization metadata, if a non‑baseline variant was chosen. */ personalization?: SelectedPersonalization } +/** Base string for resolver warning messages. */ const RESOLUTION_WARNING_BASE = '[Personalization] Could not resolve personalized entry variant:' +/** + * Resolve a personalized Contentful entry to the correct variant for the current + * selections. + * + * @public + * @remarks + * Given a baseline {@link PersonalizedEntry} and a set of selected personalizations + * (variants per experience), this resolver finds the matching replacement variant + * for the component configured against the baseline entry. + * + * **Variant indexing**: `variantIndex` in {@link SelectedPersonalization} is treated as + * 1‑based (index 1 = first variant). A value of `0` indicates baseline. + */ const PersonalizedEntryResolver = { + /** + * Find the personalization entry corresponding to one of the selected experiences. + * + * @param params - Object containing the baseline personalized entry and the selections. + * @param skipValidation - When `true`, skip type/shape validation for perf. + * @returns The matching {@link PersonalizationEntry}, or `undefined` if not found/invalid. + * @example + * ```ts + * const personalizationEntry = PersonalizedEntryResolver.getPersonalizationEntry({ + * personalizedEntry: entry, + * selectedPersonalizations + * }) + * ``` + * @remarks + * A personalization entry is a personalization configutation object supplied in a + * `PersonalizedEntry.nt_experiences` array. A personalized entry may relate to + * multiple personalizations. + */ getPersonalizationEntry( { personalizedEntry, @@ -54,6 +96,23 @@ const PersonalizedEntryResolver = { return personalizationEntry }, + /** + * Look up the selection metadata for a specific personalization entry. + * + * @param params - Object with the target personalization entry and selections. + * @param skipValidation - When `true`, skip type checks. + * @returns The matching {@link SelectedPersonalization}, if present. + * @example + * ```ts + * const selectedPersonalization = PersonalizedEntryResolver.getSelectedPersonalization({ + * personalizationEntry, + * selectedPersonalizations + * }) + * ``` + * @remarks + * Selected personalizations are supplied by the Experience API in the + * `experiences` response data property. + */ getSelectedPersonalization( { personalizationEntry, @@ -77,6 +136,25 @@ const PersonalizedEntryResolver = { return selectedPersonalization }, + /** + * Get the replacement variant config for the given selection index. + * + * @param params - Baseline entry, personalization entry, and 1‑based variant index. + * @param skipValidation - When `true`, skip type checks. + * @returns The {@link EntryReplacementVariant} for the component, if any. + * @example + * ```ts + * const selectedVariant = PersonalizedEntryResolver.getSelectedVariant({ + * personalizedEntry: entry, + * personalizationEntry, + * selectedVariantIndex: 2 // second variant (1‑based) + * }) + * ``` + * @remarks + * Entry replacement variants are variant configurations specified in a + * personalization configuration component's `variants` array supplied by the + * personalized entry via its `nt_config` field. + */ getSelectedVariant( { personalizedEntry, @@ -107,6 +185,26 @@ const PersonalizedEntryResolver = { return relevantVariants.at(selectedVariantIndex - 1) }, + /** + * Resolve the concrete Contentful entry that corresponds to a selected variant. + * + * @typeParam S - Entry skeleton type. + * @typeParam M - Chain modifiers. + * @typeParam L - Locale code. + * @param params - Personalization entry and selected variant. + * @param skipValidation - When `true`, skip type checks. + * @returns The resolved entry typed as {@link Entry} or `undefined`. + * @example + * ```ts + * const selectedVariantEntry = PersonalizedEntryResolver.getSelectedVariantEntry<{ fields: unknown }>({ + * personalizationEntry, + * selectedVariant + * }) + * ``` + * @remarks + * A personalized entry will resolve either to the baseline (the entry + * supplied as `personalizedEntry`) or the selected variant. + */ getSelectedVariantEntry< S extends EntrySkeletonType, M extends ChainModifiers = ChainModifiers, @@ -134,6 +232,23 @@ const PersonalizedEntryResolver = { return isEntry(selectedVariantEntry) ? selectedVariantEntry : undefined }, + /** + * Resolve the selected entry (baseline or variant) for a personalized entry + * and optional selected personalizations, returning both the entry and the + * personalization metadata. + * + * @typeParam S - Entry skeleton type. + * @typeParam M - Chain modifiers. + * @typeParam L - Locale code. + * @param entry - The baseline personalized entry. + * @param selectedPersonalizations - Optional selections for the current profile. + * @returns An object containing the resolved entry and (if chosen) the selection. + * @example + * ```ts + * const { entry: personalizedEntry, personalization } = PersonalizedEntryResolver.resolve(entry, selections) + * if (personalization) console.log('Variant index', personalization.variantIndex) + * ``` + */ resolve< S extends EntrySkeletonType, M extends ChainModifiers = ChainModifiers,