diff --git a/implementations/node/package.json b/implementations/node/package.json index edcfdda1..b92a48e2 100644 --- a/implementations/node/package.json +++ b/implementations/node/package.json @@ -8,9 +8,9 @@ "scripts": { "clean": "rimraf ./coverage ./playwright-report ./test-results", "serve": "pnpm serve:mocks && pnpm serve:app", - "serve:app": "pm2 start \"tsx --env-file=.env ./src/app.ts\"", - "serve:mocks": "pm2 start \"pnpm --filter mocks mocks:serve\"", - "serve:stop": "pm2 stop all && pm2 delete all", + "serve:app": "pm2 start --name node-app \"tsx --env-file=.env ./src/app.ts\"", + "serve:mocks": "pm2 start --name node-mocks \"pnpm --filter mocks mocks:serve\"", + "serve:stop": "pm2 stop node-app node-mocks && pm2 delete node-app node-mocks", "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", diff --git a/implementations/web-vanilla/package.json b/implementations/web-vanilla/package.json index 898b8fb7..7b445e6b 100644 --- a/implementations/web-vanilla/package.json +++ b/implementations/web-vanilla/package.json @@ -9,8 +9,8 @@ "clean": "rimraf ./public/dist ./coverage ./playwright-report ./test-results tsconfig.tsbuildinfo", "serve": "pnpm serve:mocks && pnpm serve:app", "serve:app": "pnpm build && docker compose up -d", - "serve:mocks": "pm2 start \"pnpm --filter mocks mocks:serve\"", - "serve:stop": "docker compose down && pm2 stop all && pm2 delete all", + "serve:mocks": "pm2 start --name web-mocks \"pnpm --filter mocks mocks:serve\"", + "serve:stop": "docker compose down && pm2 stop web-mocks && pm2 delete web-mocks", "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", diff --git a/package.json b/package.json index f3852ebc..bf22c8d1 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "Contentful Optimization SDK Suite", "license": "MIT", "scripts": { + "pm2:list": "pm2 list", "prepare": "husky", "build": "pnpm --filter @contentful/* build", "build:implementations": "pnpm --filter @implementation/* build", @@ -13,7 +14,7 @@ "format:fix": "prettier . --check --write", "lint:check": "eslint .", "lint:fix": "eslint . --fix", - "test:e2e": "pnpm --filter @implementation/* test:e2e", + "test:e2e": "pnpm --parallel --filter @implementation/* test:e2e", "test:unit": "pnpm --filter @contentful/* test:unit", "test:unit:implementations": "pnpm --filter @implementation/* test:unit", "typecheck": "pnpm -r typecheck", @@ -34,6 +35,7 @@ "husky": "^9.1.7", "jiti": "^2.5.1", "lint-staged": "^16.1.2", + "pm2": "catalog:", "prettier": "^3.6.2", "prettier-plugin-organize-imports": "^4.2.0", "typedoc": "^0.28.8", diff --git a/platforms/javascript/core/src/CoreBase.ts b/platforms/javascript/core/src/CoreBase.ts index 03809000..785354db 100644 --- a/platforms/javascript/core/src/CoreBase.ts +++ b/platforms/javascript/core/src/CoreBase.ts @@ -1,23 +1,31 @@ import ApiClient, { EventBuilder, + type InsightsEvent as AnalyticsEvent, type ApiClientConfig, - type ChangeArray, type EventBuilderConfig, type GlobalApiConfigProperties, - type Profile, - type SelectedPersonalizationArray, + type ExperienceEvent as PersonalizationEvent, } from '@contentful/optimization-api-client' import type { LogLevels } from 'logger' import { ConsoleLogSink, logger } from 'logger' import type AnalyticsBase from './analytics/AnalyticsBase' -import { Personalization } from './personalization' -import { batch, changes, consent, personalizations, profile } from './signals' +import type { AnalyticsConfigDefaults, AnalyticsStates } from './analytics/AnalyticsBase' +import { + Personalization, + type PersonalizationConfigDefaults, + type PersonalizationStates, +} from './personalization' +import { consent, event, toObservable, type Observable } from './signals' export interface CoreConfigDefaults { - changes?: ChangeArray consent?: boolean - profile?: Profile - personalizations?: SelectedPersonalizationArray + personalization?: PersonalizationConfigDefaults + analytics?: AnalyticsConfigDefaults +} + +export interface CoreStates extends AnalyticsStates, PersonalizationStates { + consent: Observable + eventStream: Observable } /** Options that may be passed to the Core constructor */ @@ -50,20 +58,9 @@ abstract class CoreBase implements ConsentController { logger.addSink(new ConsoleLogSink(logLevel)) - if (defaults) { - const { - changes: defaultChanges, - consent: defaultConsent, - personalizations: defaultPersonalizations, - profile: defaultProfile, - } = defaults - - batch(() => { - changes.value = defaultChanges - consent.value = defaultConsent - personalizations.value = defaultPersonalizations - profile.value = defaultProfile - }) + if (defaults?.consent !== undefined) { + const { consent: defaultConsent } = defaults + consent.value = defaultConsent } const apiConfig = { @@ -82,12 +79,30 @@ abstract class CoreBase implements ConsentController { }, ) - this.personalization = new Personalization(this.api, this.eventBuilder) + this.personalization = new Personalization( + this.api, + this.eventBuilder, + defaults?.personalization, + ) + } + + get states(): CoreStates { + return { + ...this.analytics.states, + ...this.personalization.states, + consent: toObservable(consent), + eventStream: toObservable(event), + } } - public consent(accept: boolean): void { + consent(accept: boolean): void { consent.value = accept } + + /** Do not reset consent, resetting personalization _currently_ also resets analytics' dependencies */ + reset(): void { + this.personalization.reset() + } } export default CoreBase diff --git a/platforms/javascript/core/src/CoreStateful.ts b/platforms/javascript/core/src/CoreStateful.ts index 375e503b..0fe88d54 100644 --- a/platforms/javascript/core/src/CoreStateful.ts +++ b/platforms/javascript/core/src/CoreStateful.ts @@ -1,57 +1,15 @@ -import type { - InsightsEvent as AnalyticsEvent, - Flags, - ExperienceEvent as PersonalizationEvent, - Profile, - SelectedPersonalizationArray, -} from '@contentful/optimization-api-client' import { AnalyticsStateful } from './analytics' import CoreBase, { type CoreConfig } from './CoreBase' -import { consent, effect, event, flags, personalizations, profile } from './signals' - -export interface Subscription { - unsubscribe: () => void -} - -export interface Observable { - subscribe: (next: (v: T) => void) => Subscription -} - -export interface States { - consent: Observable - eventStream: Observable - flags: Observable - profile: Observable - personalizations: Observable -} - -function toObservable(s: { value: T }): Observable { - return { - subscribe(next) { - const dispose = effect(() => { - next(s.value) - }) - - return { unsubscribe: dispose } - }, - } -} class CoreStateful extends CoreBase { readonly analytics: AnalyticsStateful - readonly states: States = { - consent: toObservable(consent), - eventStream: toObservable(event), - flags: toObservable(flags), - profile: toObservable(profile), - personalizations: toObservable(personalizations), - } - constructor(config: CoreConfig) { super(config) - this.analytics = new AnalyticsStateful(this.api, this.eventBuilder) + const { defaults } = config + + this.analytics = new AnalyticsStateful(this.api, this.eventBuilder, defaults?.analytics) } } diff --git a/platforms/javascript/core/src/CoreStateless.ts b/platforms/javascript/core/src/CoreStateless.ts index 9dbab449..3cd7377f 100644 --- a/platforms/javascript/core/src/CoreStateless.ts +++ b/platforms/javascript/core/src/CoreStateless.ts @@ -7,7 +7,9 @@ class CoreStateless extends CoreBase { constructor(config: CoreConfig) { super(config) - this.analytics = new AnalyticsStateless(this.api, this.eventBuilder) + const { defaults } = config + + this.analytics = new AnalyticsStateless(this.api, this.eventBuilder, defaults?.analytics) } } diff --git a/platforms/javascript/core/src/analytics/AnalyticsBase.ts b/platforms/javascript/core/src/analytics/AnalyticsBase.ts index 266da553..214fb0c9 100644 --- a/platforms/javascript/core/src/analytics/AnalyticsBase.ts +++ b/platforms/javascript/core/src/analytics/AnalyticsBase.ts @@ -1,13 +1,30 @@ import type ApiClient from '@contentful/optimization-api-client' -import type { EventBuilder, InsightsEvent } from '@contentful/optimization-api-client' +import type { EventBuilder, InsightsEvent, Profile } from '@contentful/optimization-api-client' import { logger } from 'logger' import ProductBase, { type ConsentGuard } from '../ProductBase' -import { consent, effect, profile } from '../signals' +import { consent, effect, type Observable, profile, toObservable } from '../signals' + +export interface AnalyticsConfigDefaults { + profile?: Profile +} + +export interface AnalyticsStates { + profile: Observable +} abstract class AnalyticsBase extends ProductBase implements ConsentGuard { - constructor(api: ApiClient, builder: EventBuilder) { + readonly states: AnalyticsStates = { + profile: toObservable(profile), + } + + constructor(api: ApiClient, builder: EventBuilder, defaults?: AnalyticsConfigDefaults) { super(api, builder) + if (defaults?.profile !== undefined) { + const { profile: defaultProfile } = defaults + profile.value = defaultProfile + } + effect(() => { const id = profile.value?.id diff --git a/platforms/javascript/core/src/personalization/Personalization.ts b/platforms/javascript/core/src/personalization/Personalization.ts index 9c0b389d..e45a594b 100644 --- a/platforms/javascript/core/src/personalization/Personalization.ts +++ b/platforms/javascript/core/src/personalization/Personalization.ts @@ -1,15 +1,18 @@ import type ApiClient from '@contentful/optimization-api-client' import { + type ChangeArray, type ComponentViewBuilderArgs, ComponentViewEvent, type EventBuilder, - type ExperienceEvent, type Flags, type IdentifyBuilderArgs, IdentifyEvent, type OptimizationData, type PageViewBuilderArgs, PageViewEvent, + type ExperienceEvent as PersonalizationEvent, + type Profile, + type SelectedPersonalizationArray, type TrackBuilderArgs, TrackEvent, } from '@contentful/optimization-api-client' @@ -24,18 +27,55 @@ import { consent, effect, event as eventSignal, + flags, flags as flagsSignal, + type Observable, + personalizations, personalizations as personalizationsSignal, + profile, profile as profileSignal, + toObservable, } from '../signals' import { PersonalizedEntryResolver } from './resolvers' -class Personalization extends ProductBase implements ConsentGuard { +export interface PersonalizationConfigDefaults { + changes?: ChangeArray + profile?: Profile + personalizations?: SelectedPersonalizationArray +} + +export interface PersonalizationStates { + flags: Observable + profile: Observable + personalizations: Observable +} + +class Personalization extends ProductBase implements ConsentGuard { readonly personalizedEntryResolver = PersonalizedEntryResolver - constructor(api: ApiClient, builder: EventBuilder) { + readonly states: PersonalizationStates = { + flags: toObservable(flags), + profile: toObservable(profile), + personalizations: toObservable(personalizations), + } + + constructor(api: ApiClient, builder: EventBuilder, defaults?: PersonalizationConfigDefaults) { super(api, builder) + if (defaults) { + const { + changes: defaultChanges, + personalizations: defaultPersonalizations, + profile: defaultProfile, + } = defaults + + batch(() => { + changesSignal.value = defaultChanges + personalizationsSignal.value = defaultPersonalizations + profileSignal.value = defaultProfile + }) + } + effect(() => { logger.info( `[Personalization] Profile ${profileSignal.value && `with ID ${profileSignal.value.id}`} has been ${profileSignal.value ? 'set' : 'cleared'}`, @@ -114,7 +154,7 @@ class Personalization extends ProductBase implements ConsentGua return await this.#upsertProfile(event) } - async #upsertProfile(event: ExperienceEvent): Promise { + async #upsertProfile(event: PersonalizationEvent): Promise { const intercepted = await this.interceptor.event.run(event) eventSignal.value = intercepted diff --git a/platforms/javascript/core/src/signals.ts b/platforms/javascript/core/src/signals.ts index d3564695..a3b19448 100644 --- a/platforms/javascript/core/src/signals.ts +++ b/platforms/javascript/core/src/signals.ts @@ -32,6 +32,26 @@ export interface Signals { personalizations: typeof personalizations } +export interface Subscription { + unsubscribe: () => void +} + +export interface Observable { + subscribe: (next: (v: T) => void) => Subscription +} + +export function toObservable(s: { value: T }): Observable { + return { + subscribe(next) { + const dispose = effect(() => { + next(s.value) + }) + + return { unsubscribe: dispose } + }, + } +} + export const signals: Signals = { changes, consent, diff --git a/platforms/javascript/react-native/src/index.ts b/platforms/javascript/react-native/src/index.ts index 9c7a2ad6..8270aa4b 100644 --- a/platforms/javascript/react-native/src/index.ts +++ b/platforms/javascript/react-native/src/index.ts @@ -7,13 +7,28 @@ async function mergeConfig({ defaults, logLevel, ...config }: CoreConfig): Promi // Initialize AsyncStorage before reading from it await AsyncStorageStore.initialize() + const { + consent = AsyncStorageStore.consent, + analytics: { profile: analyticsProfile = AsyncStorageStore.profile } = {}, + personalization: { + changes = AsyncStorageStore.changes, + profile: personalizationProfile = AsyncStorageStore.profile, + personalizations = AsyncStorageStore.personalizations, + } = {}, + } = defaults ?? {} + return merge( { defaults: { - changes: AsyncStorageStore.changes ?? defaults?.changes, - consent: AsyncStorageStore.consent ?? defaults?.consent, - profile: AsyncStorageStore.profile ?? defaults?.profile, - personalizations: AsyncStorageStore.personalizations ?? defaults?.personalizations, + consent, + analytics: { + profile: analyticsProfile, + }, + personalization: { + changes, + profile: personalizationProfile, + personalizations, + }, }, eventBuilder: { channel: 'react-native', diff --git a/platforms/javascript/web/src/index.ts b/platforms/javascript/web/src/index.ts index c535e47a..0bdf00d2 100644 --- a/platforms/javascript/web/src/index.ts +++ b/platforms/javascript/web/src/index.ts @@ -19,16 +19,31 @@ declare global { } function mergeConfig({ defaults, logLevel, ...config }: CoreConfig): CoreConfig { + const { + consent = LocalStore.consent, + analytics: { profile: analyticsProfile = LocalStore.profile } = {}, + personalization: { + changes = LocalStore.changes, + profile: personalizationProfile = LocalStore.profile, + personalizations = LocalStore.personalizations, + } = {}, + } = defaults ?? {} + return merge( { api: { analytics: { beaconHandler }, }, defaults: { - changes: LocalStore.changes ?? defaults?.changes, - consent: LocalStore.consent ?? defaults?.consent, - profile: LocalStore.profile ?? defaults?.profile, - personalizations: LocalStore.personalizations ?? defaults?.personalizations, + consent, + analytics: { + profile: analyticsProfile, + }, + personalization: { + changes, + profile: personalizationProfile, + personalizations, + }, }, eventBuilder: { channel: 'web', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef3cefb0..5ce94b10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: lint-staged: specifier: ^16.1.2 version: 16.1.4 + pm2: + specifier: 'catalog:' + version: 6.0.13 prettier: specifier: ^3.6.2 version: 3.6.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9b42f4f9..dac37601 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,9 +29,12 @@ nodeVersion: 20.19.5 onlyBuiltDependencies: - contentful-cli + - detox + - dtrace-provider - esbuild - msw - spawn-sync + - unrs-resolver overrides: '@playwright/test': 'catalog:'