@createcms/core 0.1.1
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.
- package/README.md +169 -0
- package/dist/ab-edge/index.cjs +214 -0
- package/dist/ab-edge/index.d.cts +121 -0
- package/dist/ab-edge/index.d.ts +121 -0
- package/dist/ab-edge/index.js +205 -0
- package/dist/bin/createcms.js +3082 -0
- package/dist/db.cjs +496 -0
- package/dist/db.d.cts +128 -0
- package/dist/db.d.ts +128 -0
- package/dist/db.js +488 -0
- package/dist/index.cjs +13789 -0
- package/dist/index.d.cts +10277 -0
- package/dist/index.d.ts +10277 -0
- package/dist/index.js +13737 -0
- package/dist/nanoid.cjs +50 -0
- package/dist/nanoid.d.cts +29 -0
- package/dist/nanoid.d.ts +29 -0
- package/dist/nanoid.js +47 -0
- package/dist/next/index.cjs +60 -0
- package/dist/next/index.d.cts +141 -0
- package/dist/next/index.d.ts +141 -0
- package/dist/next/index.js +58 -0
- package/dist/next/middleware.cjs +113 -0
- package/dist/next/middleware.d.cts +77 -0
- package/dist/next/middleware.d.ts +77 -0
- package/dist/next/middleware.js +111 -0
- package/dist/plugins/ab-test/analytics/upstash.cjs +345 -0
- package/dist/plugins/ab-test/analytics/upstash.d.cts +193 -0
- package/dist/plugins/ab-test/analytics/upstash.d.ts +193 -0
- package/dist/plugins/ab-test/analytics/upstash.js +343 -0
- package/dist/plugins/ab-test/client.cjs +686 -0
- package/dist/plugins/ab-test/client.d.cts +233 -0
- package/dist/plugins/ab-test/client.d.ts +233 -0
- package/dist/plugins/ab-test/client.js +684 -0
- package/dist/plugins/ab-test/index.cjs +3400 -0
- package/dist/plugins/ab-test/index.d.cts +1131 -0
- package/dist/plugins/ab-test/index.d.ts +1131 -0
- package/dist/plugins/ab-test/index.js +3367 -0
- package/dist/plugins/client.cjs +20 -0
- package/dist/plugins/client.d.cts +3 -0
- package/dist/plugins/client.d.ts +3 -0
- package/dist/plugins/client.js +3 -0
- package/dist/plugins/consent/client.cjs +315 -0
- package/dist/plugins/consent/client.d.cts +145 -0
- package/dist/plugins/consent/client.d.ts +145 -0
- package/dist/plugins/consent/client.js +313 -0
- package/dist/plugins/consent/index.cjs +267 -0
- package/dist/plugins/consent/index.d.cts +618 -0
- package/dist/plugins/consent/index.d.ts +618 -0
- package/dist/plugins/consent/index.js +258 -0
- package/dist/plugins/i18n/index.cjs +2177 -0
- package/dist/plugins/i18n/index.d.cts +562 -0
- package/dist/plugins/i18n/index.d.ts +562 -0
- package/dist/plugins/i18n/index.js +2150 -0
- package/dist/plugins/media-optimize/index.cjs +315 -0
- package/dist/plugins/media-optimize/index.d.cts +144 -0
- package/dist/plugins/media-optimize/index.d.ts +144 -0
- package/dist/plugins/media-optimize/index.js +311 -0
- package/dist/plugins/multi-tenant/index.cjs +210 -0
- package/dist/plugins/multi-tenant/index.d.cts +431 -0
- package/dist/plugins/multi-tenant/index.d.ts +431 -0
- package/dist/plugins/multi-tenant/index.js +207 -0
- package/dist/plugins/server.cjs +24 -0
- package/dist/plugins/server.d.cts +3 -0
- package/dist/plugins/server.d.ts +3 -0
- package/dist/plugins/server.js +3 -0
- package/dist/react/blocks.cjs +233 -0
- package/dist/react/blocks.d.cts +320 -0
- package/dist/react/blocks.d.ts +320 -0
- package/dist/react/blocks.js +226 -0
- package/dist/react/index.cjs +901 -0
- package/dist/react/index.d.cts +992 -0
- package/dist/react/index.d.ts +992 -0
- package/dist/react/index.js +872 -0
- package/dist/react/tracking.cjs +243 -0
- package/dist/react/tracking.d.cts +364 -0
- package/dist/react/tracking.d.ts +364 -0
- package/dist/react/tracking.js +216 -0
- package/dist/react/variant.cjs +59 -0
- package/dist/react/variant.d.cts +26 -0
- package/dist/react/variant.d.ts +26 -0
- package/dist/react/variant.js +57 -0
- package/package.json +303 -0
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# @createcms/core
|
|
2
|
+
|
|
3
|
+
A composable, block-based **headless CMS** powered by [better-call](https://github.com/bekacru/better-call) and [Drizzle ORM](https://orm.drizzle.team/) (PostgreSQL). Database-native, Git-like versioning with branches, copy-on-write drafts, visual diffs, merges, reusable blocks, nested pages, and a fully type-safe API.
|
|
4
|
+
|
|
5
|
+
> ⚠️ **Work in progress — not production-ready.** This package is **pre-1.0**, under
|
|
6
|
+
> active development, and has **not been tested in production**. Expect breaking
|
|
7
|
+
> changes, rough edges, and bugs (including possible data-loss edge cases). Use it for
|
|
8
|
+
> prototyping and exploration — **not** for production workloads. Pin an exact version.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- **Block-based content** — define collections as a typed `root` plus child blocks; content is a tree of blocks.
|
|
13
|
+
- **Git-like versioning** — every collection entry is a `root` with branches, commits, and snapshots. Edit on a branch, open a merge request, diff, resolve conflicts, merge, publish.
|
|
14
|
+
- **End-to-end type safety** — collection definitions drive both the write API *and* the read responses. Autocomplete on `properties` everywhere, no manual types.
|
|
15
|
+
- **Type-safe client** — a proxy-based client mirrors the server API: `client.pages.listRoots()` is fully typed from `typeof cms`.
|
|
16
|
+
- **Plugins** — extend the server API, client, hooks, schema, and request scope. Ships with multi-tenant, A/B testing, and client-side media optimization.
|
|
17
|
+
- **Framework-friendly** — first-class Next.js route mounting and React rendering helpers; the core is framework-agnostic.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @createcms/core
|
|
23
|
+
# peer deps
|
|
24
|
+
npm install drizzle-orm
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
### 0. Scaffold a project (optional)
|
|
30
|
+
|
|
31
|
+
Bootstrap the CMS config, a sample collection, and the Next.js route handler into an existing project (assumes the `src/` layout + the `@/*` path alias):
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx createcms init # interactive preset picker
|
|
35
|
+
npx createcms init --preset blog # or pick directly: pages | blog | docs
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
It writes `src/lib/cms.ts`, a collection under `src/cms/collections/`, the `src/app/api/cms/[[...rest]]/route.ts` route handler and a `.env.example`, and adds a `cms:generate` script — never overwriting existing files. Pick a starting collection with `--preset` (**pages** — marketing/content pages, the default; **blog** — posts with excerpt, date, cover image; **docs** — nested docs with callouts); the scaffolded collection is editable code you own. Provide your own Drizzle client at `src/lib/db.ts`, then continue below.
|
|
39
|
+
|
|
40
|
+
### 1. Define collections
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { defineCollection, defineCollections } from '@createcms/core';
|
|
44
|
+
|
|
45
|
+
const pages = defineCollection({
|
|
46
|
+
label: 'Pages',
|
|
47
|
+
slug: { enabled: true, root: '/', nested: true },
|
|
48
|
+
root: {
|
|
49
|
+
properties: {
|
|
50
|
+
title: { type: 'string', required: true, label: 'Title' },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
blocks: {
|
|
54
|
+
hero: {
|
|
55
|
+
label: 'Hero',
|
|
56
|
+
properties: {
|
|
57
|
+
headline: { type: 'string', required: true, label: 'Headline' },
|
|
58
|
+
align: {
|
|
59
|
+
type: 'select',
|
|
60
|
+
label: 'Align',
|
|
61
|
+
options: [
|
|
62
|
+
{ label: 'Left', value: 'left' },
|
|
63
|
+
{ label: 'Right', value: 'right' },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const collections = defineCollections({ pages });
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 2. Generate the database schema
|
|
75
|
+
|
|
76
|
+
Generate a Drizzle schema for your collections + plugins, then run your migrations as usual:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npx createcms generate
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 3. Create the CMS
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { createCMS } from '@createcms/core';
|
|
86
|
+
import { db } from './db';
|
|
87
|
+
import { collections } from './collections';
|
|
88
|
+
|
|
89
|
+
export const cms = createCMS({
|
|
90
|
+
db,
|
|
91
|
+
collections,
|
|
92
|
+
media: { /* S3 / DigitalOcean config */ },
|
|
93
|
+
authMiddleware: async (ctx) => {
|
|
94
|
+
// resolve the user / permissions for this request
|
|
95
|
+
return { userId: '...' };
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 4. Mount the HTTP router (Next.js)
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// app/api/cms/[[...rest]]/route.ts
|
|
104
|
+
import { cms } from '@/lib/cms';
|
|
105
|
+
|
|
106
|
+
const { handler } = cms.router;
|
|
107
|
+
export const GET = handler;
|
|
108
|
+
export const POST = handler;
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### 5. Read & render content
|
|
112
|
+
|
|
113
|
+
Server-side, call the typed API directly:
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import { cms } from '@/lib/cms';
|
|
117
|
+
import { BlocksRenderer, createBlocksMap } from '@createcms/core/react';
|
|
118
|
+
import { pagesCollection } from '@/cms/collections/pages/definition';
|
|
119
|
+
|
|
120
|
+
// Pass the collection DEFINITION — it types the component props and carries
|
|
121
|
+
// each block's declared `events` for A/B goal tracking (single source of truth).
|
|
122
|
+
const pageBlocks = createBlocksMap(pagesCollection, {
|
|
123
|
+
hero: ({ properties }) => <h1>{properties.headline}</h1>,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export default async function Page() {
|
|
127
|
+
const { variants } = await cms.api.pages.getPublishedContent({
|
|
128
|
+
query: { slug: '/' },
|
|
129
|
+
});
|
|
130
|
+
return <BlocksRenderer blocks={pageBlocks} tree={variants[0].tree} />;
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### 6. The type-safe client
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { createCMSClient } from '@createcms/core';
|
|
138
|
+
import type { cms } from '@/lib/cms';
|
|
139
|
+
|
|
140
|
+
export const cmsClient = createCMSClient<typeof cms>({ baseURL: '/api/cms' });
|
|
141
|
+
|
|
142
|
+
const { roots } = await cmsClient.pages.listRoots();
|
|
143
|
+
roots[0].properties.title; // typed as `string`
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Plugins
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { createCMS } from '@createcms/core';
|
|
150
|
+
import { multiTenant } from '@createcms/core/plugins/multi-tenant';
|
|
151
|
+
import { abTest } from '@createcms/core/plugins/ab-test';
|
|
152
|
+
|
|
153
|
+
export const cms = createCMS({
|
|
154
|
+
db,
|
|
155
|
+
collections,
|
|
156
|
+
media,
|
|
157
|
+
plugins: [multiTenant(), abTest({ /* ... */ })],
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
| Plugin | Entry | What it adds |
|
|
162
|
+
| --- | --- | --- |
|
|
163
|
+
| Multi-tenant | `@createcms/core/plugins/multi-tenant` | Per-tenant data isolation via request-scoped query conditions. |
|
|
164
|
+
| A/B testing | `@createcms/core/plugins/ab-test` | Deterministic variant assignment, event tracking, pluggable analytics. |
|
|
165
|
+
| Media optimize | `@createcms/core/plugins/media-optimize` | Client-side resize/compress/WebP before upload. |
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
See repository.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MurmurHash3 (32-bit) for deterministic variant assignment.
|
|
5
|
+
* Inlined to avoid an external dependency for ~30 lines of code.
|
|
6
|
+
*/ function murmur3(key, seed = 0) {
|
|
7
|
+
let h = seed >>> 0;
|
|
8
|
+
const len = key.length;
|
|
9
|
+
let i = 0;
|
|
10
|
+
while(i + 4 <= len){
|
|
11
|
+
let k = key.charCodeAt(i) & 0xff | (key.charCodeAt(i + 1) & 0xff) << 8 | (key.charCodeAt(i + 2) & 0xff) << 16 | (key.charCodeAt(i + 3) & 0xff) << 24;
|
|
12
|
+
k = Math.imul(k, 0xcc9e2d51);
|
|
13
|
+
k = k << 15 | k >>> 17;
|
|
14
|
+
k = Math.imul(k, 0x1b873593);
|
|
15
|
+
h ^= k;
|
|
16
|
+
h = h << 13 | h >>> 19;
|
|
17
|
+
h = Math.imul(h, 5) + 0xe6546b64;
|
|
18
|
+
i += 4;
|
|
19
|
+
}
|
|
20
|
+
let k = 0;
|
|
21
|
+
switch(len & 3){
|
|
22
|
+
case 3:
|
|
23
|
+
k ^= (key.charCodeAt(i + 2) & 0xff) << 16;
|
|
24
|
+
// falls through
|
|
25
|
+
case 2:
|
|
26
|
+
k ^= (key.charCodeAt(i + 1) & 0xff) << 8;
|
|
27
|
+
// falls through
|
|
28
|
+
case 1:
|
|
29
|
+
k ^= key.charCodeAt(i) & 0xff;
|
|
30
|
+
k = Math.imul(k, 0xcc9e2d51);
|
|
31
|
+
k = k << 15 | k >>> 17;
|
|
32
|
+
k = Math.imul(k, 0x1b873593);
|
|
33
|
+
h ^= k;
|
|
34
|
+
}
|
|
35
|
+
h ^= len;
|
|
36
|
+
h ^= h >>> 16;
|
|
37
|
+
h = Math.imul(h, 0x85ebca6b);
|
|
38
|
+
h ^= h >>> 13;
|
|
39
|
+
h = Math.imul(h, 0xc2b2ae35);
|
|
40
|
+
h ^= h >>> 16;
|
|
41
|
+
return h >>> 0;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Deterministic variant assignment.
|
|
45
|
+
*
|
|
46
|
+
* The same `contextKey + testId` always produces the same variant.
|
|
47
|
+
* No DB writes needed -- pure function.
|
|
48
|
+
*
|
|
49
|
+
* @param contextKey - Visitor identifier (user ID or anonymous key)
|
|
50
|
+
* @param testId - The A/B test ID
|
|
51
|
+
* @param trafficPercentage - 0-100, how much total traffic enters the test
|
|
52
|
+
* @param variants - Must be sorted by id for stability
|
|
53
|
+
*/ function resolveVariant(contextKey, testId, trafficPercentage, variants) {
|
|
54
|
+
const hash = murmur3(contextKey + ':' + testId);
|
|
55
|
+
const bucket = hash % 10000;
|
|
56
|
+
const control = variants.find((v)=>v.isControl);
|
|
57
|
+
if (bucket >= trafficPercentage * 100) {
|
|
58
|
+
return {
|
|
59
|
+
variantId: control.id,
|
|
60
|
+
inTest: false
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const sorted = [
|
|
64
|
+
...variants
|
|
65
|
+
].sort((a, b)=>a.id.localeCompare(b.id));
|
|
66
|
+
const normalizedBucket = Math.floor(bucket * 100 / (trafficPercentage * 100));
|
|
67
|
+
let cumulative = 0;
|
|
68
|
+
for (const v of sorted){
|
|
69
|
+
cumulative += v.weight;
|
|
70
|
+
if (normalizedBucket < cumulative) {
|
|
71
|
+
return {
|
|
72
|
+
variantId: v.id,
|
|
73
|
+
inTest: true
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
variantId: control.id,
|
|
79
|
+
inTest: true
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The control sentinel code. Pattern A ALWAYS rewrites (Vercel "precompute"
|
|
85
|
+
* model): a request with no running test / no consent / outside traffic is
|
|
86
|
+
* rewritten to `/<CONTROL_CODE><pathname>` so it still lands on the single
|
|
87
|
+
* `[abVariant]` route — there is no un-coded passthrough. It is never a real
|
|
88
|
+
* branch id (ids are prefixed), and the render route maps it to the control
|
|
89
|
+
* tree (pickVariant fails closed to control for any non-variant code anyway).
|
|
90
|
+
*/ const CONTROL_CODE = 'control';
|
|
91
|
+
/**
|
|
92
|
+
* The static path prefix the render route lives under (`app/<prefix>/[abVariant]/
|
|
93
|
+
* [[...rest]]`). Pattern A rewrites every public request UNDER this prefix so the
|
|
94
|
+
* variant-coded route never sits at the URL root — otherwise a root catch-all
|
|
95
|
+
* would shadow sibling app routes (e.g. a `/app/*` dashboard). It is internal +
|
|
96
|
+
* transparent (the browser keeps the original URL), so the value only needs to
|
|
97
|
+
* differ from your real top-level app prefixes; with always-rewrite even a real
|
|
98
|
+
* page slugged `/ab` still works (it is rewritten THROUGH the prefix).
|
|
99
|
+
*/ const DEFAULT_VARIANT_PREFIX = '/ab';
|
|
100
|
+
/**
|
|
101
|
+
* Build the variant-coded rewrite path: `<prefix>/<code><pathname>`. The variant
|
|
102
|
+
* code is the first segment AFTER the static prefix — the cache key — never a
|
|
103
|
+
* header/searchParam, so each variant is its own CDN/ISR cache entry (Vercel
|
|
104
|
+
* Flags precompute pattern, nested under `<prefix>` to coexist with other app
|
|
105
|
+
* routes). The matching render route reads the code segment back.
|
|
106
|
+
*/ function variantRewritePath(prefix, code, pathname) {
|
|
107
|
+
// For the root path, omit the trailing slash (`<prefix>/<code>`, not
|
|
108
|
+
// `<prefix>/<code>/`) so the optional-catch-all variant route matches cleanly.
|
|
109
|
+
const suffix = pathname === '/' ? '' : pathname;
|
|
110
|
+
return `${prefix}/${encodeURIComponent(code)}${suffix}`;
|
|
111
|
+
}
|
|
112
|
+
/** Parse a first-party consent cookie value; analytics granted → true. */ function parseConsentCookie(value) {
|
|
113
|
+
if (!value) return false;
|
|
114
|
+
try {
|
|
115
|
+
const state = JSON.parse(decodeURIComponent(value));
|
|
116
|
+
return state.analytics_storage === 'granted';
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Edge-safe random visitor id (mirrors the client `anon_` key shape). Uses the
|
|
123
|
+
* `crypto` global (Web Crypto), available in every edge/worker/browser runtime.
|
|
124
|
+
*/ function generateEdgeVisitorId() {
|
|
125
|
+
const bytes = new Uint8Array(16);
|
|
126
|
+
crypto.getRandomValues(bytes);
|
|
127
|
+
let hex = '';
|
|
128
|
+
for (const b of bytes)hex += b.toString(16).padStart(2, '0');
|
|
129
|
+
return `anon_${hex}`;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* PURE bucketing: given a one-shot `key`, the branch to render IN-TEST, or null =
|
|
133
|
+
* serve control (no running test, or the roll lands outside the test traffic).
|
|
134
|
+
* NO consent / NO persistent identifier — the caller passes an EPHEMERAL random
|
|
135
|
+
* key for a first assignment and then persists only the chosen branch (a
|
|
136
|
+
* variant-only cookie), so nothing identifying is stored. Reuses the same
|
|
137
|
+
* weighted hash as the server pick.
|
|
138
|
+
*/ function pickEdgeVariant(opts) {
|
|
139
|
+
const test = opts.resolve.test;
|
|
140
|
+
if (!test) return {
|
|
141
|
+
branchId: null
|
|
142
|
+
};
|
|
143
|
+
const result = resolveVariant(opts.key, test.testId, test.trafficPercentage, test.variants.map((v)=>({
|
|
144
|
+
id: v.variantId,
|
|
145
|
+
weight: v.weight,
|
|
146
|
+
isControl: v.isControl
|
|
147
|
+
})));
|
|
148
|
+
if (!result.inTest) return {
|
|
149
|
+
branchId: null
|
|
150
|
+
}; // outside traffic → control
|
|
151
|
+
const picked = test.variants.find((v)=>v.variantId === result.variantId);
|
|
152
|
+
return {
|
|
153
|
+
branchId: picked?.branchId ?? null
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The framework-agnostic edge decision (ALWAYS-rewrite / Vercel precompute).
|
|
158
|
+
* CONSENT-FREE by design: serving a variant + a VARIANT-ONLY cookie (no visitor
|
|
159
|
+
* id, no behavioural data, no third-party transmission) falls under the ePrivacy
|
|
160
|
+
* "strictly necessary" exemption, so fresh ad traffic gets real variants (not
|
|
161
|
+
* always control).
|
|
162
|
+
*
|
|
163
|
+
* - `assignedCode` is the value of the test's variant cookie (`ab_<testId>`) — a
|
|
164
|
+
* branch id, or the control sentinel — from a previous visit. If still valid
|
|
165
|
+
* it is REUSED (consistent variant across requests, no re-roll).
|
|
166
|
+
* - Otherwise a FIRST assignment is rolled from an ephemeral random key; the
|
|
167
|
+
* chosen code is returned in `assignCode` for the adapter to persist in the
|
|
168
|
+
* variant cookie. Out-of-traffic → the control sentinel (no impression).
|
|
169
|
+
*
|
|
170
|
+
* `rewritePath` is always non-null. Returns the `testId` so the adapter knows
|
|
171
|
+
* which cookie to set. No persistent identifier is ever minted here — the
|
|
172
|
+
* consent-gated visitor id + GA4 forwarding live in the client pipeline.
|
|
173
|
+
*/ function decideEdgeVariant(input) {
|
|
174
|
+
const controlCode = input.controlCode ?? CONTROL_CODE;
|
|
175
|
+
const prefix = input.variantPrefix ?? DEFAULT_VARIANT_PREFIX;
|
|
176
|
+
const test = input.resolve.test;
|
|
177
|
+
if (!test) {
|
|
178
|
+
return {
|
|
179
|
+
rewritePath: variantRewritePath(prefix, controlCode, input.pathname),
|
|
180
|
+
assignCode: null,
|
|
181
|
+
testId: null
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// Reuse a still-valid prior assignment (a published variant branch, or the
|
|
185
|
+
// control sentinel for an out-of-traffic visitor) → consistent, no re-roll.
|
|
186
|
+
const reusable = input.assignedCode === controlCode || input.assignedCode != null && test.variants.some((v)=>v.branchId === input.assignedCode);
|
|
187
|
+
if (reusable) {
|
|
188
|
+
return {
|
|
189
|
+
rewritePath: variantRewritePath(prefix, input.assignedCode, input.pathname),
|
|
190
|
+
assignCode: null,
|
|
191
|
+
testId: test.testId
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
// First assignment: roll once from an ephemeral (non-persisted) random key.
|
|
195
|
+
const { branchId } = pickEdgeVariant({
|
|
196
|
+
key: generateEdgeVisitorId(),
|
|
197
|
+
resolve: input.resolve
|
|
198
|
+
});
|
|
199
|
+
const code = branchId ?? controlCode; // out-of-traffic → control sentinel
|
|
200
|
+
return {
|
|
201
|
+
rewritePath: variantRewritePath(prefix, code, input.pathname),
|
|
202
|
+
assignCode: code,
|
|
203
|
+
testId: test.testId
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
exports.CONTROL_CODE = CONTROL_CODE;
|
|
208
|
+
exports.DEFAULT_VARIANT_PREFIX = DEFAULT_VARIANT_PREFIX;
|
|
209
|
+
exports.decideEdgeVariant = decideEdgeVariant;
|
|
210
|
+
exports.generateEdgeVisitorId = generateEdgeVisitorId;
|
|
211
|
+
exports.parseConsentCookie = parseConsentCookie;
|
|
212
|
+
exports.pickEdgeVariant = pickEdgeVariant;
|
|
213
|
+
exports.resolveVariant = resolveVariant;
|
|
214
|
+
exports.variantRewritePath = variantRewritePath;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** One variant branch of the resolved test (enough for edge bucketing). */
|
|
2
|
+
type ResolvedAbVariant = {
|
|
3
|
+
/** ab_test_variants.id — the `variantId` resolveVariant buckets to. */
|
|
4
|
+
variantId: string;
|
|
5
|
+
/** The published branch this variant renders. */
|
|
6
|
+
branchId: string;
|
|
7
|
+
weight: number;
|
|
8
|
+
isControl: boolean;
|
|
9
|
+
};
|
|
10
|
+
type AbResolveResult = {
|
|
11
|
+
test: {
|
|
12
|
+
testId: string;
|
|
13
|
+
/** The root the test attaches to (page root or an embedded block root). */
|
|
14
|
+
rootId: string;
|
|
15
|
+
trafficPercentage: number;
|
|
16
|
+
variants: ResolvedAbVariant[];
|
|
17
|
+
} | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type VariantInput = {
|
|
21
|
+
id: string;
|
|
22
|
+
weight: number;
|
|
23
|
+
isControl: boolean;
|
|
24
|
+
};
|
|
25
|
+
type AssignmentResult = {
|
|
26
|
+
variantId: string;
|
|
27
|
+
inTest: boolean;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Deterministic variant assignment.
|
|
31
|
+
*
|
|
32
|
+
* The same `contextKey + testId` always produces the same variant.
|
|
33
|
+
* No DB writes needed -- pure function.
|
|
34
|
+
*
|
|
35
|
+
* @param contextKey - Visitor identifier (user ID or anonymous key)
|
|
36
|
+
* @param testId - The A/B test ID
|
|
37
|
+
* @param trafficPercentage - 0-100, how much total traffic enters the test
|
|
38
|
+
* @param variants - Must be sorted by id for stability
|
|
39
|
+
*/
|
|
40
|
+
declare function resolveVariant(contextKey: string, testId: string, trafficPercentage: number, variants: VariantInput[]): AssignmentResult;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The control sentinel code. Pattern A ALWAYS rewrites (Vercel "precompute"
|
|
44
|
+
* model): a request with no running test / no consent / outside traffic is
|
|
45
|
+
* rewritten to `/<CONTROL_CODE><pathname>` so it still lands on the single
|
|
46
|
+
* `[abVariant]` route — there is no un-coded passthrough. It is never a real
|
|
47
|
+
* branch id (ids are prefixed), and the render route maps it to the control
|
|
48
|
+
* tree (pickVariant fails closed to control for any non-variant code anyway).
|
|
49
|
+
*/
|
|
50
|
+
declare const CONTROL_CODE = "control";
|
|
51
|
+
/**
|
|
52
|
+
* The static path prefix the render route lives under (`app/<prefix>/[abVariant]/
|
|
53
|
+
* [[...rest]]`). Pattern A rewrites every public request UNDER this prefix so the
|
|
54
|
+
* variant-coded route never sits at the URL root — otherwise a root catch-all
|
|
55
|
+
* would shadow sibling app routes (e.g. a `/app/*` dashboard). It is internal +
|
|
56
|
+
* transparent (the browser keeps the original URL), so the value only needs to
|
|
57
|
+
* differ from your real top-level app prefixes; with always-rewrite even a real
|
|
58
|
+
* page slugged `/ab` still works (it is rewritten THROUGH the prefix).
|
|
59
|
+
*/
|
|
60
|
+
declare const DEFAULT_VARIANT_PREFIX = "/ab";
|
|
61
|
+
/**
|
|
62
|
+
* Build the variant-coded rewrite path: `<prefix>/<code><pathname>`. The variant
|
|
63
|
+
* code is the first segment AFTER the static prefix — the cache key — never a
|
|
64
|
+
* header/searchParam, so each variant is its own CDN/ISR cache entry (Vercel
|
|
65
|
+
* Flags precompute pattern, nested under `<prefix>` to coexist with other app
|
|
66
|
+
* routes). The matching render route reads the code segment back.
|
|
67
|
+
*/
|
|
68
|
+
declare function variantRewritePath(prefix: string, code: string, pathname: string): string;
|
|
69
|
+
/** Parse a first-party consent cookie value; analytics granted → true. */
|
|
70
|
+
declare function parseConsentCookie(value: string | undefined | null): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Edge-safe random visitor id (mirrors the client `anon_` key shape). Uses the
|
|
73
|
+
* `crypto` global (Web Crypto), available in every edge/worker/browser runtime.
|
|
74
|
+
*/
|
|
75
|
+
declare function generateEdgeVisitorId(): string;
|
|
76
|
+
/**
|
|
77
|
+
* PURE bucketing: given a one-shot `key`, the branch to render IN-TEST, or null =
|
|
78
|
+
* serve control (no running test, or the roll lands outside the test traffic).
|
|
79
|
+
* NO consent / NO persistent identifier — the caller passes an EPHEMERAL random
|
|
80
|
+
* key for a first assignment and then persists only the chosen branch (a
|
|
81
|
+
* variant-only cookie), so nothing identifying is stored. Reuses the same
|
|
82
|
+
* weighted hash as the server pick.
|
|
83
|
+
*/
|
|
84
|
+
declare function pickEdgeVariant(opts: {
|
|
85
|
+
key: string;
|
|
86
|
+
resolve: AbResolveResult;
|
|
87
|
+
}): {
|
|
88
|
+
branchId: string | null;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* The framework-agnostic edge decision (ALWAYS-rewrite / Vercel precompute).
|
|
92
|
+
* CONSENT-FREE by design: serving a variant + a VARIANT-ONLY cookie (no visitor
|
|
93
|
+
* id, no behavioural data, no third-party transmission) falls under the ePrivacy
|
|
94
|
+
* "strictly necessary" exemption, so fresh ad traffic gets real variants (not
|
|
95
|
+
* always control).
|
|
96
|
+
*
|
|
97
|
+
* - `assignedCode` is the value of the test's variant cookie (`ab_<testId>`) — a
|
|
98
|
+
* branch id, or the control sentinel — from a previous visit. If still valid
|
|
99
|
+
* it is REUSED (consistent variant across requests, no re-roll).
|
|
100
|
+
* - Otherwise a FIRST assignment is rolled from an ephemeral random key; the
|
|
101
|
+
* chosen code is returned in `assignCode` for the adapter to persist in the
|
|
102
|
+
* variant cookie. Out-of-traffic → the control sentinel (no impression).
|
|
103
|
+
*
|
|
104
|
+
* `rewritePath` is always non-null. Returns the `testId` so the adapter knows
|
|
105
|
+
* which cookie to set. No persistent identifier is ever minted here — the
|
|
106
|
+
* consent-gated visitor id + GA4 forwarding live in the client pipeline.
|
|
107
|
+
*/
|
|
108
|
+
declare function decideEdgeVariant(input: {
|
|
109
|
+
pathname: string;
|
|
110
|
+
resolve: AbResolveResult;
|
|
111
|
+
assignedCode: string | null;
|
|
112
|
+
controlCode?: string;
|
|
113
|
+
variantPrefix?: string;
|
|
114
|
+
}): {
|
|
115
|
+
rewritePath: string;
|
|
116
|
+
assignCode: string | null;
|
|
117
|
+
testId: string | null;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export { CONTROL_CODE, DEFAULT_VARIANT_PREFIX, decideEdgeVariant, generateEdgeVisitorId, parseConsentCookie, pickEdgeVariant, resolveVariant, variantRewritePath };
|
|
121
|
+
export type { AbResolveResult, AssignmentResult, ResolvedAbVariant, VariantInput };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** One variant branch of the resolved test (enough for edge bucketing). */
|
|
2
|
+
type ResolvedAbVariant = {
|
|
3
|
+
/** ab_test_variants.id — the `variantId` resolveVariant buckets to. */
|
|
4
|
+
variantId: string;
|
|
5
|
+
/** The published branch this variant renders. */
|
|
6
|
+
branchId: string;
|
|
7
|
+
weight: number;
|
|
8
|
+
isControl: boolean;
|
|
9
|
+
};
|
|
10
|
+
type AbResolveResult = {
|
|
11
|
+
test: {
|
|
12
|
+
testId: string;
|
|
13
|
+
/** The root the test attaches to (page root or an embedded block root). */
|
|
14
|
+
rootId: string;
|
|
15
|
+
trafficPercentage: number;
|
|
16
|
+
variants: ResolvedAbVariant[];
|
|
17
|
+
} | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type VariantInput = {
|
|
21
|
+
id: string;
|
|
22
|
+
weight: number;
|
|
23
|
+
isControl: boolean;
|
|
24
|
+
};
|
|
25
|
+
type AssignmentResult = {
|
|
26
|
+
variantId: string;
|
|
27
|
+
inTest: boolean;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Deterministic variant assignment.
|
|
31
|
+
*
|
|
32
|
+
* The same `contextKey + testId` always produces the same variant.
|
|
33
|
+
* No DB writes needed -- pure function.
|
|
34
|
+
*
|
|
35
|
+
* @param contextKey - Visitor identifier (user ID or anonymous key)
|
|
36
|
+
* @param testId - The A/B test ID
|
|
37
|
+
* @param trafficPercentage - 0-100, how much total traffic enters the test
|
|
38
|
+
* @param variants - Must be sorted by id for stability
|
|
39
|
+
*/
|
|
40
|
+
declare function resolveVariant(contextKey: string, testId: string, trafficPercentage: number, variants: VariantInput[]): AssignmentResult;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The control sentinel code. Pattern A ALWAYS rewrites (Vercel "precompute"
|
|
44
|
+
* model): a request with no running test / no consent / outside traffic is
|
|
45
|
+
* rewritten to `/<CONTROL_CODE><pathname>` so it still lands on the single
|
|
46
|
+
* `[abVariant]` route — there is no un-coded passthrough. It is never a real
|
|
47
|
+
* branch id (ids are prefixed), and the render route maps it to the control
|
|
48
|
+
* tree (pickVariant fails closed to control for any non-variant code anyway).
|
|
49
|
+
*/
|
|
50
|
+
declare const CONTROL_CODE = "control";
|
|
51
|
+
/**
|
|
52
|
+
* The static path prefix the render route lives under (`app/<prefix>/[abVariant]/
|
|
53
|
+
* [[...rest]]`). Pattern A rewrites every public request UNDER this prefix so the
|
|
54
|
+
* variant-coded route never sits at the URL root — otherwise a root catch-all
|
|
55
|
+
* would shadow sibling app routes (e.g. a `/app/*` dashboard). It is internal +
|
|
56
|
+
* transparent (the browser keeps the original URL), so the value only needs to
|
|
57
|
+
* differ from your real top-level app prefixes; with always-rewrite even a real
|
|
58
|
+
* page slugged `/ab` still works (it is rewritten THROUGH the prefix).
|
|
59
|
+
*/
|
|
60
|
+
declare const DEFAULT_VARIANT_PREFIX = "/ab";
|
|
61
|
+
/**
|
|
62
|
+
* Build the variant-coded rewrite path: `<prefix>/<code><pathname>`. The variant
|
|
63
|
+
* code is the first segment AFTER the static prefix — the cache key — never a
|
|
64
|
+
* header/searchParam, so each variant is its own CDN/ISR cache entry (Vercel
|
|
65
|
+
* Flags precompute pattern, nested under `<prefix>` to coexist with other app
|
|
66
|
+
* routes). The matching render route reads the code segment back.
|
|
67
|
+
*/
|
|
68
|
+
declare function variantRewritePath(prefix: string, code: string, pathname: string): string;
|
|
69
|
+
/** Parse a first-party consent cookie value; analytics granted → true. */
|
|
70
|
+
declare function parseConsentCookie(value: string | undefined | null): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Edge-safe random visitor id (mirrors the client `anon_` key shape). Uses the
|
|
73
|
+
* `crypto` global (Web Crypto), available in every edge/worker/browser runtime.
|
|
74
|
+
*/
|
|
75
|
+
declare function generateEdgeVisitorId(): string;
|
|
76
|
+
/**
|
|
77
|
+
* PURE bucketing: given a one-shot `key`, the branch to render IN-TEST, or null =
|
|
78
|
+
* serve control (no running test, or the roll lands outside the test traffic).
|
|
79
|
+
* NO consent / NO persistent identifier — the caller passes an EPHEMERAL random
|
|
80
|
+
* key for a first assignment and then persists only the chosen branch (a
|
|
81
|
+
* variant-only cookie), so nothing identifying is stored. Reuses the same
|
|
82
|
+
* weighted hash as the server pick.
|
|
83
|
+
*/
|
|
84
|
+
declare function pickEdgeVariant(opts: {
|
|
85
|
+
key: string;
|
|
86
|
+
resolve: AbResolveResult;
|
|
87
|
+
}): {
|
|
88
|
+
branchId: string | null;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* The framework-agnostic edge decision (ALWAYS-rewrite / Vercel precompute).
|
|
92
|
+
* CONSENT-FREE by design: serving a variant + a VARIANT-ONLY cookie (no visitor
|
|
93
|
+
* id, no behavioural data, no third-party transmission) falls under the ePrivacy
|
|
94
|
+
* "strictly necessary" exemption, so fresh ad traffic gets real variants (not
|
|
95
|
+
* always control).
|
|
96
|
+
*
|
|
97
|
+
* - `assignedCode` is the value of the test's variant cookie (`ab_<testId>`) — a
|
|
98
|
+
* branch id, or the control sentinel — from a previous visit. If still valid
|
|
99
|
+
* it is REUSED (consistent variant across requests, no re-roll).
|
|
100
|
+
* - Otherwise a FIRST assignment is rolled from an ephemeral random key; the
|
|
101
|
+
* chosen code is returned in `assignCode` for the adapter to persist in the
|
|
102
|
+
* variant cookie. Out-of-traffic → the control sentinel (no impression).
|
|
103
|
+
*
|
|
104
|
+
* `rewritePath` is always non-null. Returns the `testId` so the adapter knows
|
|
105
|
+
* which cookie to set. No persistent identifier is ever minted here — the
|
|
106
|
+
* consent-gated visitor id + GA4 forwarding live in the client pipeline.
|
|
107
|
+
*/
|
|
108
|
+
declare function decideEdgeVariant(input: {
|
|
109
|
+
pathname: string;
|
|
110
|
+
resolve: AbResolveResult;
|
|
111
|
+
assignedCode: string | null;
|
|
112
|
+
controlCode?: string;
|
|
113
|
+
variantPrefix?: string;
|
|
114
|
+
}): {
|
|
115
|
+
rewritePath: string;
|
|
116
|
+
assignCode: string | null;
|
|
117
|
+
testId: string | null;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export { CONTROL_CODE, DEFAULT_VARIANT_PREFIX, decideEdgeVariant, generateEdgeVisitorId, parseConsentCookie, pickEdgeVariant, resolveVariant, variantRewritePath };
|
|
121
|
+
export type { AbResolveResult, AssignmentResult, ResolvedAbVariant, VariantInput };
|