diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1fbc7e5..8e7bee4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,3 +33,18 @@ export { evaluateExperience, evaluateUrlRule, } from './runtime'; + +// Export singleton API +export { + createInstance, + init, + register, + evaluate, + explain, + getState, + on, + destroy, + experiences as default, +} from './singleton'; + + diff --git a/packages/core/src/placeholder.test.ts b/packages/core/src/placeholder.test.ts deleted file mode 100644 index 336ceb2..0000000 --- a/packages/core/src/placeholder.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -describe('Placeholder', () => { - it('should pass', () => { - expect(true).toBe(true); - }); -}); - diff --git a/packages/core/src/singleton.test.ts b/packages/core/src/singleton.test.ts new file mode 100644 index 0000000..282bcb8 --- /dev/null +++ b/packages/core/src/singleton.test.ts @@ -0,0 +1,237 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + createInstance, + init, + register, + evaluate, + explain, + getState, + on, + destroy, + experiences as experiencesDefault, +} from './singleton'; + +describe('Export Pattern', () => { + beforeEach(async () => { + // Clean up singleton between tests + await destroy(); + }); + + describe('createInstance()', () => { + it('should create a new isolated instance', () => { + const instance1 = createInstance(); + const instance2 = createInstance(); + + expect(instance1).toBeDefined(); + expect(instance2).toBeDefined(); + expect(instance1).not.toBe(instance2); + }); + + it('should accept config', () => { + const instance = createInstance({ debug: true }); + + expect(instance).toBeDefined(); + }); + + it('should create independent instances', () => { + const instance1 = createInstance(); + const instance2 = createInstance(); + + instance1.register('test1', { + type: 'banner', + targeting: {}, + content: { title: 'Test 1', message: 'Message 1' }, + }); + + instance2.register('test2', { + type: 'banner', + targeting: {}, + content: { title: 'Test 2', message: 'Message 2' }, + }); + + const state1 = instance1.getState(); + const state2 = instance2.getState(); + + expect(state1.experiences.has('test1')).toBe(true); + expect(state1.experiences.has('test2')).toBe(false); + + expect(state2.experiences.has('test1')).toBe(false); + expect(state2.experiences.has('test2')).toBe(true); + }); + }); + + describe('Singleton API', () => { + it('should initialize singleton', async () => { + await init({ debug: true }); + + const state = getState(); + expect(state.initialized).toBe(true); + }); + + it('should register experiences on singleton', () => { + register('singleton-test', { + type: 'banner', + targeting: {}, + content: { title: 'Singleton', message: 'Test' }, + }); + + const state = getState(); + expect(state.experiences.has('singleton-test')).toBe(true); + }); + + it('should evaluate on singleton', () => { + register('test', { + type: 'banner', + targeting: { url: { contains: '/test' } }, + content: { title: 'Test', message: 'Test' }, + }); + + const decision = evaluate({ url: 'https://example.com/test' }); + + expect(decision.show).toBe(true); + expect(decision.experienceId).toBe('test'); + }); + + it('should explain on singleton', () => { + register('test', { + type: 'banner', + targeting: { url: { contains: '/test' } }, + content: { title: 'Test', message: 'Test' }, + }); + + const explanation = explain('test'); + + expect(explanation).not.toBeNull(); + expect(explanation?.experienceId).toBe('test'); + }); + + it('should get state from singleton', () => { + register('test', { + type: 'banner', + targeting: {}, + content: { title: 'Test', message: 'Test' }, + }); + + const state = getState(); + + expect(state.experiences.size).toBe(1); + expect(state.experiences.has('test')).toBe(true); + }); + + it('should subscribe to events on singleton', () => { + let called = false; + const unsubscribe = on('experiences:registered', () => { + called = true; + }); + + register('test', { + type: 'banner', + targeting: {}, + content: { title: 'Test', message: 'Test' }, + }); + + expect(called).toBe(true); + unsubscribe(); + }); + + it('should destroy singleton', async () => { + await init(); + register('test', { + type: 'banner', + targeting: {}, + content: { title: 'Test', message: 'Test' }, + }); + + await destroy(); + + const state = getState(); + expect(state.initialized).toBe(false); + expect(state.experiences.size).toBe(0); + }); + }); + + describe('Default Export', () => { + it('should have all methods', () => { + expect(experiencesDefault.createInstance).toBeDefined(); + expect(experiencesDefault.init).toBeDefined(); + expect(experiencesDefault.register).toBeDefined(); + expect(experiencesDefault.evaluate).toBeDefined(); + expect(experiencesDefault.explain).toBeDefined(); + expect(experiencesDefault.getState).toBeDefined(); + expect(experiencesDefault.on).toBeDefined(); + expect(experiencesDefault.destroy).toBeDefined(); + }); + + it('should work via default export', async () => { + await experiencesDefault.init(); + + experiencesDefault.register('default-test', { + type: 'banner', + targeting: {}, + content: { title: 'Default', message: 'Test' }, + }); + + const state = experiencesDefault.getState(); + expect(state.experiences.has('default-test')).toBe(true); + + await experiencesDefault.destroy(); + }); + }); + + describe('Instance Isolation', () => { + it('should not share state between custom instance and singleton', () => { + const customInstance = createInstance(); + + // Register on singleton + register('singleton-exp', { + type: 'banner', + targeting: {}, + content: { title: 'Singleton', message: 'Test' }, + }); + + // Register on custom instance + customInstance.register('custom-exp', { + type: 'banner', + targeting: {}, + content: { title: 'Custom', message: 'Test' }, + }); + + // Check singleton state + const singletonState = getState(); + expect(singletonState.experiences.has('singleton-exp')).toBe(true); + expect(singletonState.experiences.has('custom-exp')).toBe(false); + + // Check custom instance state + const customState = customInstance.getState(); + expect(customState.experiences.has('singleton-exp')).toBe(false); + expect(customState.experiences.has('custom-exp')).toBe(true); + }); + + it('should not share decisions between instances', () => { + const customInstance = createInstance(); + + // Evaluate on singleton + register('test1', { + type: 'banner', + targeting: {}, + content: { title: 'Test 1', message: 'Test' }, + }); + evaluate(); + + // Evaluate on custom instance + customInstance.register('test2', { + type: 'banner', + targeting: {}, + content: { title: 'Test 2', message: 'Test' }, + }); + customInstance.evaluate(); + + const singletonState = getState(); + const customState = customInstance.getState(); + + expect(singletonState.decisions.length).toBe(1); + expect(customState.decisions.length).toBe(1); + }); + }); +}); + diff --git a/packages/core/src/singleton.ts b/packages/core/src/singleton.ts new file mode 100644 index 0000000..96e53ed --- /dev/null +++ b/packages/core/src/singleton.ts @@ -0,0 +1,177 @@ +/** + * Singleton Pattern Implementation + * + * Provides a default singleton instance with convenient wrapper functions + * for simple use cases, plus createInstance() for advanced scenarios. + */ + +import { ExperienceRuntime } from './runtime'; +import type { ExperienceConfig, Experience, Context, Decision, RuntimeState } from './types'; + +/** + * Create a new Experience SDK instance + * + * Use this for advanced scenarios where you need multiple isolated runtimes. + * + * @example + * ```typescript + * import { createInstance } from '@prosdevlab/experience-sdk'; + * + * const exp = createInstance({ debug: true }); + * await exp.init(); + * exp.register('welcome', { ... }); + * ``` + */ +export function createInstance(config?: ExperienceConfig): ExperienceRuntime { + return new ExperienceRuntime(config); +} + +/** + * Default singleton instance + * + * Provides a convenient global instance for simple use cases. + * For script tag users, this is exposed as the global `experiences` object. + */ +const defaultInstance = createInstance(); + +/** + * Initialize the Experience SDK + * + * @example + * ```typescript + * import { init } from '@prosdevlab/experience-sdk'; + * await init({ debug: true }); + * ``` + */ +export async function init(config?: ExperienceConfig): Promise { + return defaultInstance.init(config); +} + +/** + * Register an experience + * + * @example + * ```typescript + * import { register } from '@prosdevlab/experience-sdk'; + * + * register('welcome-banner', { + * type: 'banner', + * targeting: { url: { contains: '/' } }, + * content: { title: 'Welcome!', message: 'Thanks for visiting' } + * }); + * ``` + */ +export function register(id: string, experience: Omit): void { + defaultInstance.register(id, experience); +} + +/** + * Evaluate experiences against current context + * + * @example + * ```typescript + * import { evaluate } from '@prosdevlab/experience-sdk'; + * + * const decision = evaluate({ url: window.location.href }); + * if (decision.show) { + * console.log('Show experience:', decision.experienceId); + * console.log('Reasons:', decision.reasons); + * } + * ``` + */ +export function evaluate(context?: Partial): Decision { + return defaultInstance.evaluate(context); +} + +/** + * Explain why a specific experience would/wouldn't show + * + * @example + * ```typescript + * import { explain } from '@prosdevlab/experience-sdk'; + * + * const explanation = explain('welcome-banner'); + * console.log('Would show?', explanation?.show); + * console.log('Reasons:', explanation?.reasons); + * ``` + */ +export function explain(experienceId: string): Decision | null { + return defaultInstance.explain(experienceId); +} + +/** + * Get current runtime state + * + * @example + * ```typescript + * import { getState } from '@prosdevlab/experience-sdk'; + * + * const state = getState(); + * console.log('Initialized?', state.initialized); + * console.log('Experiences:', Array.from(state.experiences.keys())); + * ``` + */ +export function getState(): RuntimeState { + return defaultInstance.getState(); +} + +/** + * Subscribe to SDK events + * + * @example + * ```typescript + * import { on } from '@prosdevlab/experience-sdk'; + * + * const unsubscribe = on('experiences:evaluated', (decision) => { + * console.log('Evaluation:', decision); + * }); + * + * // Later: unsubscribe() + * ``` + */ +export function on(event: string, handler: (...args: unknown[]) => void): () => void { + return defaultInstance.on(event, handler); +} + +/** + * Destroy the SDK instance + * + * @example + * ```typescript + * import { destroy } from '@prosdevlab/experience-sdk'; + * await destroy(); + * ``` + */ +export async function destroy(): Promise { + return defaultInstance.destroy(); +} + +/** + * Default export for convenient importing + * + * @example + * ```typescript + * import experiences from '@prosdevlab/experience-sdk'; + * await experiences.init(); + * ``` + */ +export const experiences = { + createInstance, + init, + register, + evaluate, + explain, + getState, + on, + destroy, +}; + +/** + * Global singleton instance for IIFE builds + * + * When loaded via script tag, this object is available as `window.experiences` + */ +if (typeof window !== 'undefined') { + (window as unknown as Record).experiences = experiences; +} +