@se-studio/ab-testing 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +393 -0
  2. package/dist/hooks/index.d.ts +8 -0
  3. package/dist/hooks/index.d.ts.map +1 -0
  4. package/dist/hooks/index.js +7 -0
  5. package/dist/hooks/index.js.map +1 -0
  6. package/dist/hooks/useAbTestAssignments.d.ts +55 -0
  7. package/dist/hooks/useAbTestAssignments.d.ts.map +1 -0
  8. package/dist/hooks/useAbTestAssignments.js +87 -0
  9. package/dist/hooks/useAbTestAssignments.js.map +1 -0
  10. package/dist/index.d.ts +15 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +14 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/middleware/assignment.d.ts +36 -0
  15. package/dist/middleware/assignment.d.ts.map +1 -0
  16. package/dist/middleware/assignment.js +91 -0
  17. package/dist/middleware/assignment.js.map +1 -0
  18. package/dist/middleware/cache.d.ts +26 -0
  19. package/dist/middleware/cache.d.ts.map +1 -0
  20. package/dist/middleware/cache.js +128 -0
  21. package/dist/middleware/cache.js.map +1 -0
  22. package/dist/middleware/cookies.d.ts +44 -0
  23. package/dist/middleware/cookies.d.ts.map +1 -0
  24. package/dist/middleware/cookies.js +66 -0
  25. package/dist/middleware/cookies.js.map +1 -0
  26. package/dist/middleware/handler.d.ts +45 -0
  27. package/dist/middleware/handler.d.ts.map +1 -0
  28. package/dist/middleware/handler.js +189 -0
  29. package/dist/middleware/handler.js.map +1 -0
  30. package/dist/middleware/index.d.ts +11 -0
  31. package/dist/middleware/index.d.ts.map +1 -0
  32. package/dist/middleware/index.js +10 -0
  33. package/dist/middleware/index.js.map +1 -0
  34. package/dist/middleware/types.d.ts +78 -0
  35. package/dist/middleware/types.d.ts.map +1 -0
  36. package/dist/middleware/types.js +2 -0
  37. package/dist/middleware/types.js.map +1 -0
  38. package/dist/types.d.ts +125 -0
  39. package/dist/types.d.ts.map +1 -0
  40. package/dist/types.js +7 -0
  41. package/dist/types.js.map +1 -0
  42. package/dist/webhook/handler.d.ts +116 -0
  43. package/dist/webhook/handler.d.ts.map +1 -0
  44. package/dist/webhook/handler.js +123 -0
  45. package/dist/webhook/handler.js.map +1 -0
  46. package/dist/webhook/index.d.ts +8 -0
  47. package/dist/webhook/index.d.ts.map +1 -0
  48. package/dist/webhook/index.js +7 -0
  49. package/dist/webhook/index.js.map +1 -0
  50. package/package.json +74 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClF,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC3F,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,aAAa,EACb,WAAW,EACX,sBAAsB,EACtB,eAAe,EACf,aAAa,GACd,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAC"}
@@ -0,0 +1,78 @@
1
+ import type { AbTest, AbTestAssignment, IBlobStore } from '../types';
2
+ /**
3
+ * Cached test data with pre-computed weights and lookups for fast middleware execution.
4
+ */
5
+ export interface CachedAbTest extends AbTest {
6
+ /** Pre-computed weights map: variantId/control -> weight */
7
+ readyWeights: Record<string, number>;
8
+ /** Pre-computed lookup: variantId/control -> slug */
9
+ variantLookup: Record<string, string>;
10
+ /** Pre-computed lookup: variantId/control -> hubspot event name */
11
+ hubspotEventLookup: Record<string, string | undefined>;
12
+ }
13
+ /**
14
+ * In-memory cache structure for A/B tests.
15
+ */
16
+ export interface TestsCache {
17
+ /** Timestamp when the cache was last refreshed */
18
+ timestamp: number;
19
+ /** Tests indexed by normalized control path */
20
+ testsByPath: Map<string, CachedAbTest[]>;
21
+ }
22
+ /**
23
+ * Configuration for the A/B test middleware.
24
+ */
25
+ export interface AbTestMiddlewareConfig {
26
+ /**
27
+ * Factory function that returns the blob store instance.
28
+ * Called when the cache needs to be refreshed.
29
+ */
30
+ getStore: () => IBlobStore<AbTest> | Promise<IBlobStore<AbTest>>;
31
+ /**
32
+ * Cache time-to-live in milliseconds.
33
+ * @default 60000 (60 seconds)
34
+ */
35
+ cacheTtlMs?: number;
36
+ /**
37
+ * Name of the cookie used to store A/B test assignments.
38
+ * @default "ab-test-info"
39
+ */
40
+ cookieName?: string;
41
+ /**
42
+ * Cookie max age in seconds.
43
+ * @default 2592000 (30 days)
44
+ */
45
+ cookieMaxAge?: number;
46
+ /**
47
+ * Optional function to determine if middleware should process this request.
48
+ * Return false to skip A/B testing for this request.
49
+ * @default Always returns true
50
+ */
51
+ shouldProcess?: (pathname: string) => boolean;
52
+ /**
53
+ * Optional test data for development mode.
54
+ * When provided and in development, this data is used instead of fetching from store.
55
+ */
56
+ devTestData?: AbTest[];
57
+ /**
58
+ * Optional callback for validation failures.
59
+ * Called when a cookie assignment fails validation against current config.
60
+ */
61
+ onValidationFailure?: (testId: string, assignment: AbTestAssignment) => void;
62
+ }
63
+ /**
64
+ * Result of processing an A/B test request.
65
+ */
66
+ export interface AbTestResult {
67
+ /** Whether a test was matched and processed */
68
+ matched: boolean;
69
+ /** The test that was matched (if any) */
70
+ test?: CachedAbTest;
71
+ /** The assignment for this user (if any) */
72
+ assignment?: AbTestAssignment;
73
+ /** The URL to rewrite to (if variant assigned) */
74
+ rewriteUrl?: string;
75
+ /** Updated cookie value to set */
76
+ cookieValue?: string;
77
+ }
78
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/middleware/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAErE;;GAEG;AACH,MAAM,WAAW,YAAa,SAAQ,MAAM;IAC1C,4DAA4D;IAC5D,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,qDAAqD;IACrD,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,mEAAmE;IACnE,kBAAkB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;CACxD;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB,+CAA+C;IAC/C,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,EAAE,CAAC,CAAC;CAC1C;AAED;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,QAAQ,EAAE,MAAM,UAAU,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC;IAEjE;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IAE9C;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IAEvB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,gBAAgB,KAAK,IAAI,CAAC;CAC9E;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,+CAA+C;IAC/C,OAAO,EAAE,OAAO,CAAC;IACjB,yCAAyC;IACzC,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,4CAA4C;IAC5C,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,kDAAkD;IAClD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kCAAkC;IAClC,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/middleware/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,125 @@
1
+ /**
2
+ * A/B Testing Types
3
+ *
4
+ * Core type definitions for server-side A/B testing.
5
+ */
6
+ /**
7
+ * Represents a single variant in an A/B test.
8
+ */
9
+ export interface AbTestVariant {
10
+ /** Contentful entry ID of the PageVariant */
11
+ id: string;
12
+ /** URL slug of the variant page */
13
+ slug: string;
14
+ }
15
+ /**
16
+ * Configuration for a single variant in an A/B test.
17
+ * Index 0 is the control, subsequent indices correspond to variants.
18
+ */
19
+ export interface AbTestVariantConfig {
20
+ /** Traffic weight for this variant (0-1, all weights should sum to 1) */
21
+ weight: number;
22
+ /** Optional HubSpot behavioral event name to fire for this variant */
23
+ hubspot_event_name?: string;
24
+ }
25
+ /**
26
+ * A/B test configuration stored in the blob store.
27
+ * This is the runtime representation of a PageTest entry.
28
+ */
29
+ export interface AbTest {
30
+ /** Contentful entry ID of the PageTest */
31
+ id: string;
32
+ /** Internal CMS label for the test */
33
+ cmsLabel: string;
34
+ /** URL path slug of the control page (e.g., "pricing" or "lp/special-offer") */
35
+ controlSlug: string;
36
+ /** Optional URL query parameters to match (e.g., "utm_source=google") */
37
+ searchParameters?: string;
38
+ /** Optional override for analytics tracking label (defaults to cmsLabel) */
39
+ trackingLabel?: string;
40
+ /** Whether the test is currently active */
41
+ enabled: boolean;
42
+ /** Array of variant configurations with weights. Index 0 = control, 1+ = variants */
43
+ configuration: AbTestVariantConfig[];
44
+ /** Array of variants to test against the control */
45
+ variants: AbTestVariant[];
46
+ }
47
+ /**
48
+ * Assignment data stored in the A/B test cookie.
49
+ * Each test has its own assignment keyed by test ID.
50
+ */
51
+ export interface AbTestAssignment {
52
+ /** Analytics tracking label for this test */
53
+ test_label: string;
54
+ /** Slug of the assigned variant, or "control" */
55
+ test_path: string;
56
+ /** Optional HubSpot event name for this variant */
57
+ hubspot_event?: string;
58
+ /** Original URL path where the test was assigned */
59
+ original_path?: string;
60
+ }
61
+ /**
62
+ * Cookie structure for A/B test assignments.
63
+ * Key is the test ID, value is the assignment data.
64
+ */
65
+ export type AbTestCookie = Record<string, AbTestAssignment>;
66
+ /**
67
+ * Generic blob store interface for A/B test configuration storage.
68
+ *
69
+ * Projects must implement this interface for their hosting platform:
70
+ * - Vercel: Use Vercel KV
71
+ * - Netlify: Use Netlify Blobs
72
+ * - Local development: Use file system
73
+ *
74
+ * @example Vercel KV implementation
75
+ * ```typescript
76
+ * import { kv } from '@vercel/kv';
77
+ * import type { IBlobStore, AbTest } from '@se-studio/ab-testing';
78
+ *
79
+ * export function getAbTestStore(): IBlobStore<AbTest> {
80
+ * return {
81
+ * async get(key) { return kv.get(`ab-test:${key}`); },
82
+ * async set(key, value) { await kv.set(`ab-test:${key}`, value); },
83
+ * async bulkWrite(entries) {
84
+ * const pipeline = kv.pipeline();
85
+ * for (const [key, value] of entries) {
86
+ * pipeline.set(`ab-test:${key}`, value);
87
+ * }
88
+ * await pipeline.exec();
89
+ * },
90
+ * async size() { return (await kv.keys('ab-test:*')).length; },
91
+ * async values() { return kv.mget(...(await kv.keys('ab-test:*'))); },
92
+ * };
93
+ * }
94
+ * ```
95
+ */
96
+ export interface IBlobStore<T> {
97
+ /**
98
+ * Get a value by key
99
+ * @param key - The key to look up
100
+ * @returns The value, or undefined if not found
101
+ */
102
+ get(key: string): Promise<T | undefined>;
103
+ /**
104
+ * Set a value by key
105
+ * @param key - The key to store under
106
+ * @param value - The value to store
107
+ */
108
+ set(key: string, value: T): Promise<void>;
109
+ /**
110
+ * Replace all entries in the store with new entries
111
+ * @param entries - Array of [key, value] tuples to store
112
+ */
113
+ bulkWrite(entries: [string, T][]): Promise<void>;
114
+ /**
115
+ * Get the number of entries in the store
116
+ * @returns The count of stored entries
117
+ */
118
+ size(): Promise<number>;
119
+ /**
120
+ * Get all values in the store
121
+ * @returns Array of all stored values
122
+ */
123
+ values(): Promise<T[]>;
124
+ }
125
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,6CAA6C;IAC7C,EAAE,EAAE,MAAM,CAAC;IACX,mCAAmC;IACnC,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;GAGG;AACH,MAAM,WAAW,mBAAmB;IAClC,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;IACf,sEAAsE;IACtE,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;GAGG;AACH,MAAM,WAAW,MAAM;IACrB,0CAA0C;IAC1C,EAAE,EAAE,MAAM,CAAC;IACX,sCAAsC;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,gFAAgF;IAChF,WAAW,EAAE,MAAM,CAAC;IACpB,yEAAyE;IACzE,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,2CAA2C;IAC3C,OAAO,EAAE,OAAO,CAAC;IACjB,qFAAqF;IACrF,aAAa,EAAE,mBAAmB,EAAE,CAAC;IACrC,oDAAoD;IACpD,QAAQ,EAAE,aAAa,EAAE,CAAC;CAC3B;AAED;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,6CAA6C;IAC7C,UAAU,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,oDAAoD;IACpD,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;AAE5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,WAAW,UAAU,CAAC,CAAC;IAC3B;;;;OAIG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC;IAEzC;;;;OAIG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE1C;;;OAGG;IACH,SAAS,CAAC,OAAO,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEjD;;;OAGG;IACH,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAExB;;;OAGG;IACH,MAAM,IAAI,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC;CACxB"}
package/dist/types.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * A/B Testing Types
3
+ *
4
+ * Core type definitions for server-side A/B testing.
5
+ */
6
+ export {};
7
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}
@@ -0,0 +1,116 @@
1
+ import type { AbTest, IBlobStore } from '../types';
2
+ /**
3
+ * Raw PageTest data from Contentful (before transformation).
4
+ * This interface represents the expected shape of data from your Contentful fetch.
5
+ */
6
+ export interface RawPageTest {
7
+ /** Contentful system metadata */
8
+ sys: {
9
+ id: string;
10
+ };
11
+ /** CMS label for the test */
12
+ cmsLabel?: string | null;
13
+ /** Whether the test is enabled */
14
+ enabled?: boolean | null;
15
+ /** Optional tracking label override */
16
+ trackingLabel?: string | null;
17
+ /** Optional search parameters to match */
18
+ searchParameters?: string | null;
19
+ /** Variant configuration JSON */
20
+ configuration?: Array<{
21
+ weight: number;
22
+ hubspot_event_name?: string;
23
+ }> | null;
24
+ /** Control page reference */
25
+ control?: {
26
+ sys?: {
27
+ id: string;
28
+ };
29
+ slug?: string | null;
30
+ } | null;
31
+ /** Variants collection */
32
+ variantsCollection?: {
33
+ items?: Array<{
34
+ sys: {
35
+ id: string;
36
+ };
37
+ slug?: string | null;
38
+ } | null> | null;
39
+ } | null;
40
+ }
41
+ /**
42
+ * Configuration for the webhook handler.
43
+ */
44
+ export interface WebhookHandlerConfig {
45
+ /**
46
+ * Function to fetch all active PageTest entries from Contentful.
47
+ * This should return the raw data from your GraphQL or REST API call.
48
+ */
49
+ fetchPageTests: () => Promise<RawPageTest[]>;
50
+ /**
51
+ * Function to get the blob store instance.
52
+ */
53
+ getStore: () => IBlobStore<AbTest> | Promise<IBlobStore<AbTest>>;
54
+ /**
55
+ * Optional secret for webhook authentication.
56
+ * If provided, the webhook will validate the x-contentful-webhook-secret header.
57
+ */
58
+ webhookSecret?: string;
59
+ /**
60
+ * Optional callback when a test is skipped (e.g., no control slug).
61
+ */
62
+ onSkippedTest?: (testId: string, reason: string) => void;
63
+ /**
64
+ * Optional revalidation function to call after updating the store.
65
+ * Useful for clearing Next.js cache tags.
66
+ */
67
+ revalidate?: () => void | Promise<void>;
68
+ }
69
+ /**
70
+ * Result of processing the webhook.
71
+ */
72
+ export interface WebhookResult {
73
+ /** Whether the webhook was processed successfully */
74
+ success: boolean;
75
+ /** Number of tests stored */
76
+ count: number;
77
+ /** Optional error message */
78
+ error?: string;
79
+ }
80
+ /**
81
+ * Transform raw Contentful PageTest data into AbTest format.
82
+ */
83
+ export declare function transformPageTest(raw: RawPageTest, onSkipped?: (testId: string, reason: string) => void): AbTest | null;
84
+ /**
85
+ * Process all PageTest entries and store them in the blob store.
86
+ *
87
+ * @param config - Webhook handler configuration
88
+ * @returns Result with success status and count
89
+ */
90
+ export declare function processAbTestWebhook(config: WebhookHandlerConfig): Promise<WebhookResult>;
91
+ /**
92
+ * Create a Next.js API route handler for the A/B test webhook.
93
+ *
94
+ * @param config - Webhook handler configuration
95
+ * @returns POST handler function
96
+ *
97
+ * @example
98
+ * ```typescript
99
+ * // app/api/webhooks/ab-test/route.ts
100
+ * import { createWebhookHandler } from '@se-studio/ab-testing/webhook';
101
+ * import { getAbTestStore } from '@/server/abTestStore';
102
+ * import { fetchAllPageTests } from '@/cms/pageTests';
103
+ * import { revalidateTag } from 'next/cache';
104
+ *
105
+ * export const dynamic = 'force-dynamic';
106
+ *
107
+ * export const POST = createWebhookHandler({
108
+ * fetchPageTests: () => fetchAllPageTests(false), // preview=false
109
+ * getStore: getAbTestStore,
110
+ * webhookSecret: process.env.CONTENTFUL_WEBHOOK_SECRET,
111
+ * revalidate: () => revalidateTag('PageTests'),
112
+ * });
113
+ * ```
114
+ */
115
+ export declare function createWebhookHandler(config: WebhookHandlerConfig): (request: Request) => Promise<Response>;
116
+ //# sourceMappingURL=handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/webhook/handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAEnD;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,iCAAiC;IACjC,GAAG,EAAE;QACH,EAAE,EAAE,MAAM,CAAC;KACZ,CAAC;IACF,6BAA6B;IAC7B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,kCAAkC;IAClC,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IACzB,uCAAuC;IACvC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,0CAA0C;IAC1C,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,iCAAiC;IACjC,aAAa,CAAC,EAAE,KAAK,CAAC;QACpB,MAAM,EAAE,MAAM,CAAC;QACf,kBAAkB,CAAC,EAAE,MAAM,CAAC;KAC7B,CAAC,GAAG,IAAI,CAAC;IACV,6BAA6B;IAC7B,OAAO,CAAC,EAAE;QACR,GAAG,CAAC,EAAE;YAAE,EAAE,EAAE,MAAM,CAAA;SAAE,CAAC;QACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KACtB,GAAG,IAAI,CAAC;IACT,0BAA0B;IAC1B,kBAAkB,CAAC,EAAE;QACnB,KAAK,CAAC,EAAE,KAAK,CAAC;YACZ,GAAG,EAAE;gBAAE,EAAE,EAAE,MAAM,CAAA;aAAE,CAAC;YACpB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;SACtB,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC;KAClB,GAAG,IAAI,CAAC;CACV;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,cAAc,EAAE,MAAM,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;IAE7C;;OAEG;IACH,QAAQ,EAAE,MAAM,UAAU,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC;IAEjE;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;OAEG;IACH,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAEzD;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzC;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,qDAAqD;IACrD,OAAO,EAAE,OAAO,CAAC;IACjB,6BAA6B;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,6BAA6B;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,WAAW,EAChB,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,GACnD,MAAM,GAAG,IAAI,CAyBf;AAED;;;;;GAKG;AACH,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,oBAAoB,GAAG,OAAO,CAAC,aAAa,CAAC,CAuC/F;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,oBAAoB,IACjC,SAAS,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAsCnE"}
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Transform raw Contentful PageTest data into AbTest format.
3
+ */
4
+ export function transformPageTest(raw, onSkipped) {
5
+ const controlSlug = raw.control?.slug;
6
+ if (!controlSlug) {
7
+ onSkipped?.(raw.sys.id, 'No control slug');
8
+ return null;
9
+ }
10
+ const variants = (raw.variantsCollection?.items ?? [])
11
+ .filter((item) => item !== null && item.slug !== null)
12
+ .map((item) => ({
13
+ id: item.sys.id,
14
+ slug: item.slug,
15
+ }));
16
+ return {
17
+ id: raw.sys.id,
18
+ cmsLabel: raw.cmsLabel || '',
19
+ controlSlug,
20
+ searchParameters: raw.searchParameters ?? undefined,
21
+ trackingLabel: raw.trackingLabel ?? undefined,
22
+ enabled: raw.enabled ?? false,
23
+ configuration: raw.configuration ?? [],
24
+ variants,
25
+ };
26
+ }
27
+ /**
28
+ * Process all PageTest entries and store them in the blob store.
29
+ *
30
+ * @param config - Webhook handler configuration
31
+ * @returns Result with success status and count
32
+ */
33
+ export async function processAbTestWebhook(config) {
34
+ const { fetchPageTests, getStore, onSkippedTest, revalidate } = config;
35
+ try {
36
+ // Revalidate cache tags if function provided
37
+ if (revalidate) {
38
+ await Promise.resolve(revalidate());
39
+ }
40
+ // Fetch all active tests from Contentful
41
+ const rawTests = await fetchPageTests();
42
+ // Transform and index by control slug
43
+ const abTests = {};
44
+ for (const raw of rawTests) {
45
+ const test = transformPageTest(raw, onSkippedTest);
46
+ if (test) {
47
+ abTests[test.controlSlug] = test;
48
+ }
49
+ }
50
+ // Store in blob store
51
+ const store = await Promise.resolve(getStore());
52
+ await store.bulkWrite(Object.entries(abTests));
53
+ return {
54
+ success: true,
55
+ count: Object.keys(abTests).length,
56
+ };
57
+ }
58
+ catch (error) {
59
+ // biome-ignore lint/suspicious/noConsole: Error logging in webhook
60
+ console.error('Error processing ab-test webhook:', error);
61
+ return {
62
+ success: false,
63
+ count: 0,
64
+ error: error instanceof Error ? error.message : 'Unknown error',
65
+ };
66
+ }
67
+ }
68
+ /**
69
+ * Create a Next.js API route handler for the A/B test webhook.
70
+ *
71
+ * @param config - Webhook handler configuration
72
+ * @returns POST handler function
73
+ *
74
+ * @example
75
+ * ```typescript
76
+ * // app/api/webhooks/ab-test/route.ts
77
+ * import { createWebhookHandler } from '@se-studio/ab-testing/webhook';
78
+ * import { getAbTestStore } from '@/server/abTestStore';
79
+ * import { fetchAllPageTests } from '@/cms/pageTests';
80
+ * import { revalidateTag } from 'next/cache';
81
+ *
82
+ * export const dynamic = 'force-dynamic';
83
+ *
84
+ * export const POST = createWebhookHandler({
85
+ * fetchPageTests: () => fetchAllPageTests(false), // preview=false
86
+ * getStore: getAbTestStore,
87
+ * webhookSecret: process.env.CONTENTFUL_WEBHOOK_SECRET,
88
+ * revalidate: () => revalidateTag('PageTests'),
89
+ * });
90
+ * ```
91
+ */
92
+ export function createWebhookHandler(config) {
93
+ return async function handler(request) {
94
+ // Validate webhook secret if configured
95
+ if (config.webhookSecret) {
96
+ const secret = request.headers.get('x-contentful-webhook-secret');
97
+ if (secret !== config.webhookSecret) {
98
+ return new Response(JSON.stringify({ message: 'Invalid secret' }), {
99
+ status: 401,
100
+ headers: { 'Content-Type': 'application/json' },
101
+ });
102
+ }
103
+ }
104
+ const result = await processAbTestWebhook(config);
105
+ if (result.success) {
106
+ return new Response(JSON.stringify({
107
+ message: 'Success',
108
+ count: result.count,
109
+ }), {
110
+ status: 200,
111
+ headers: { 'Content-Type': 'application/json' },
112
+ });
113
+ }
114
+ return new Response(JSON.stringify({
115
+ message: 'Error processing webhook',
116
+ error: result.error,
117
+ }), {
118
+ status: 500,
119
+ headers: { 'Content-Type': 'application/json' },
120
+ });
121
+ };
122
+ }
123
+ //# sourceMappingURL=handler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.js","sourceRoot":"","sources":["../../src/webhook/handler.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * A/B Testing Webhook Handlers
3
+ *
4
+ * Utilities for processing Contentful webhooks to sync PageTest configurations.
5
+ */
6
+ export type { RawPageTest, WebhookHandlerConfig, WebhookResult, } from './handler';
7
+ export { createWebhookHandler, processAbTestWebhook, transformPageTest, } from './handler';
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/webhook/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,YAAY,EACV,WAAW,EACX,oBAAoB,EACpB,aAAa,GACd,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,iBAAiB,GAClB,MAAM,WAAW,CAAC"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * A/B Testing Webhook Handlers
3
+ *
4
+ * Utilities for processing Contentful webhooks to sync PageTest configurations.
5
+ */
6
+ export { createWebhookHandler, processAbTestWebhook, transformPageTest, } from './handler';
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/webhook/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAOH,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,iBAAiB,GAClB,MAAM,WAAW,CAAC"}
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@se-studio/ab-testing",
3
+ "version": "1.0.0",
4
+ "description": "Server-side A/B testing framework for Next.js applications with Contentful CMS",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/Something-Else-Studio/se-core-product",
8
+ "directory": "packages/ab-testing"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ },
19
+ "./middleware": {
20
+ "types": "./dist/middleware/index.d.ts",
21
+ "import": "./dist/middleware/index.js"
22
+ },
23
+ "./webhook": {
24
+ "types": "./dist/webhook/index.d.ts",
25
+ "import": "./dist/webhook/index.js"
26
+ },
27
+ "./hooks": {
28
+ "types": "./dist/hooks/index.d.ts",
29
+ "import": "./dist/hooks/index.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "*.md"
35
+ ],
36
+ "keywords": [
37
+ "ab-testing",
38
+ "a/b-testing",
39
+ "experiment",
40
+ "nextjs",
41
+ "next.js",
42
+ "middleware",
43
+ "contentful",
44
+ "cms",
45
+ "typescript",
46
+ "react",
47
+ "analytics"
48
+ ],
49
+ "homepage": "https://github.com/Something-Else-Studio/se-core-product#readme",
50
+ "bugs": {
51
+ "url": "https://github.com/Something-Else-Studio/se-core-product/issues"
52
+ },
53
+ "peerDependencies": {
54
+ "next": "^15.0.0",
55
+ "react": "^19.0.0"
56
+ },
57
+ "devDependencies": {
58
+ "@biomejs/biome": "^2.3.10",
59
+ "@types/node": "^22.19.3",
60
+ "@types/react": "^19.2.7",
61
+ "next": "^15.5.9",
62
+ "typescript": "^5.9.3",
63
+ "vitest": "^4.0.16"
64
+ },
65
+ "scripts": {
66
+ "build": "tsc --project tsconfig.build.json",
67
+ "dev": "tsc --project tsconfig.build.json --watch",
68
+ "test": "vitest run",
69
+ "test:watch": "vitest",
70
+ "type-check": "tsc --noEmit",
71
+ "lint": "biome lint .",
72
+ "clean": "rm -rf dist .turbo *.tsbuildinfo"
73
+ }
74
+ }