@gradial/aci 0.1.48 → 0.1.51-ssr.2
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 +13 -20
- package/dist/astro/assets.d.ts +6 -6
- package/dist/astro/assets.js +6 -6
- package/dist/astro/index.d.ts +0 -2
- package/dist/astro/index.js +0 -4
- package/dist/cli/emit-error-pages.mjs +0 -1
- package/dist/cli/verify-next-cdn-routes.mjs +3 -4
- package/dist/collect/aci.js +1 -1
- package/dist/collect/aci.js.sha256 +1 -1
- package/dist/collect/webvitals.d.ts +0 -3
- package/dist/collect/webvitals.js +11 -21
- package/dist/content/cache.d.ts +1 -9
- package/dist/content/cache.js +2 -37
- package/dist/content/error-pages.d.ts +1 -2
- package/dist/content/index.d.ts +0 -1
- package/dist/content/index.js +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/internal/domain/edge/identity.d.ts +14 -0
- package/dist/internal/domain/edge/identity.js +214 -0
- package/dist/internal/domain/edge/index.d.ts +4 -0
- package/dist/internal/domain/edge/index.js +6 -0
- package/dist/internal/domain/edge/personalization-core.d.ts +24 -0
- package/dist/{edge → internal/domain/edge}/personalization-core.js +165 -64
- package/dist/internal/domain/edge/record/codec.d.ts +71 -0
- package/dist/internal/domain/edge/record/codec.js +401 -0
- package/dist/internal/domain/edge/resolve.d.ts +2 -0
- package/dist/internal/domain/edge/resolve.js +76 -0
- package/dist/{next/personalization-host.d.ts → internal/domain/edge/types.d.ts} +110 -61
- package/dist/internal/domain/edge/types.js +7 -0
- package/dist/internal/platform/collectionstore/errors.d.ts +5 -0
- package/dist/internal/platform/collectionstore/errors.js +7 -0
- package/dist/next/config.d.ts +0 -8
- package/dist/next/config.js +3 -78
- package/dist/next/error-pages.d.ts +2 -3
- package/dist/next/error-pages.js +5 -5
- package/dist/next/error-seat.d.ts +2 -3
- package/dist/next/index.d.ts +2 -2
- package/dist/next/index.js +1 -2
- package/dist/next/page.d.ts +3 -13
- package/dist/next/page.js +39 -51
- package/dist/next/server.d.ts +12 -32
- package/dist/next/server.js +66 -295
- package/dist/providers/object.d.ts +24 -0
- package/dist/providers/object.js +105 -0
- package/dist/providers/vercel-object-reader.d.ts +20 -0
- package/dist/providers/vercel-object-reader.js +45 -0
- package/dist/testing/conformance/checks.d.ts +1 -1
- package/dist/testing/conformance/checks.js +14 -144
- package/dist/testing/conformance/index.d.ts +1 -1
- package/dist/testing/conformance/index.js +1 -1
- package/dist/testing/conformance/preset.js +1 -1
- package/dist/testing/conformance/types.d.ts +1 -1
- package/dist/types/config.d.ts +0 -4
- package/dist/types/index.d.ts +0 -2
- package/dist/types/index.js +0 -2
- package/dist/types/page.d.ts +0 -10
- package/package.json +11 -35
- package/src/cli/emit-error-pages.mjs +0 -1
- package/src/cli/verify-next-cdn-routes.mjs +8 -11
- package/src/types/config.ts +0 -5
- package/src/types/index.ts +0 -2
- package/src/types/page.ts +0 -11
- package/templates/astro/template/.aci.yaml.tmpl +0 -11
- package/templates/common/.agents/skills/aci/SKILL.md +8 -9
- package/templates/common/.agents/skills/authoring/SKILL.md +3 -4
- package/templates/common/.agents/skills/authoring/references/block-registry.md +13 -36
- package/templates/common/.agents/skills/authoring/references/contracts.md +10 -17
- package/templates/common/.agents/skills/authoring/references/repo-structure.md +2 -2
- package/templates/common/.agents/skills/authoring/references/sdk-server-functions.md +45 -95
- package/templates/common/.agents/skills/migration/references/canon-migration.md +17 -37
- package/templates/nextjs/template/.aci.yaml.tmpl +0 -11
- package/templates/nextjs/template/README.md.tmpl +0 -1
- package/templates/nextjs/template/src/app/[[...slug]]/page.tsx +1 -1
- package/dist/content/assets.d.ts +0 -18
- package/dist/content/assets.js +0 -66
- package/dist/edge/personalization-core.d.ts +0 -11
- package/dist/internal/render-proof.d.ts +0 -1
- package/dist/internal/render-proof.js +0 -197
- package/dist/next/asset-route.d.ts +0 -10
- package/dist/next/asset-route.js +0 -45
- package/dist/next/collect-route.d.ts +0 -19
- package/dist/next/collect-route.js +0 -422
- package/dist/next/edge-config.d.ts +0 -1
- package/dist/next/edge-config.js +0 -92
- package/dist/next/envelope-digest.d.ts +0 -21
- package/dist/next/envelope-digest.js +0 -58
- package/dist/next/middleware.d.ts +0 -40
- package/dist/next/middleware.js +0 -462
- package/dist/next/personalization-host.js +0 -257
- package/dist/next/vercel-collect-sdk-route.d.ts +0 -27
- package/dist/next/vercel-collect-sdk-route.js +0 -181
- package/dist/next/vercel-edge-projection-v1.d.ts +0 -24
- package/dist/next/vercel-edge-projection-v1.js +0 -880
- package/dist/next/vercel-personalization.d.ts +0 -4
- package/dist/next/vercel-personalization.js +0 -4
- package/dist/next/vercel-s3-personalization.d.ts +0 -79
- package/dist/next/vercel-s3-personalization.js +0 -570
- package/dist/providers/s3.d.ts +0 -47
- package/dist/providers/s3.js +0 -434
- package/dist/types/render-mode.d.ts +0 -2
- package/dist/types/render-mode.js +0 -1
- package/dist/types/renderer.d.ts +0 -71
- package/dist/types/renderer.js +0 -1
- package/src/types/render-mode.ts +0 -10
- package/src/types/renderer.ts +0 -83
- package/templates/nextjs/starter-content/src/middleware.ts +0 -41
- package/templates/nextjs/template/src/app/gradial/assets/[...path]/route.ts +0 -1
- package/templates/nextjs/template/src/middleware.ts +0 -41
- package/templates/nextjs/template/src/middleware.ts.tmpl +0 -40
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Identity, consent, and cookie helpers for edge decisioning.
|
|
2
|
+
//
|
|
3
|
+
// This is the single source of truth for:
|
|
4
|
+
// - Cookie parsing
|
|
5
|
+
// - Consent signal extraction (GPC/DNT + portable consent envelope)
|
|
6
|
+
// - Identity cookie verification and minting (aci_id)
|
|
7
|
+
// - Claims cookie verification (aci_claims)
|
|
8
|
+
// - Cookie mutation helpers
|
|
9
|
+
//
|
|
10
|
+
// Shared by every static edge adapter; this logic has no SDK host wrapper.
|
|
11
|
+
// internal/domain/audience/runtime/identity.ts imports from here as well.
|
|
12
|
+
const encoder = new TextEncoder();
|
|
13
|
+
const decoder = new TextDecoder();
|
|
14
|
+
// ── Constants ──
|
|
15
|
+
export const DEFAULT_IDENTITY_TTL_SECONDS = 90 * 24 * 60 * 60;
|
|
16
|
+
const MAX_CONSENT_ENVELOPE_BYTES = 2048;
|
|
17
|
+
const MAX_CONSENT_PURPOSES = 32;
|
|
18
|
+
const MAX_CONSENT_PURPOSE_BYTES = 128;
|
|
19
|
+
const MAX_CONSENT_SOURCE_BYTES = 512;
|
|
20
|
+
const MAX_CONSENT_POLICY_VERSION = 2147483647;
|
|
21
|
+
const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/;
|
|
22
|
+
const CONSENT_EDGE_WHITESPACE = /^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g;
|
|
23
|
+
const LEGACY_GRANTS = new Set([
|
|
24
|
+
'true',
|
|
25
|
+
'all',
|
|
26
|
+
'granted',
|
|
27
|
+
'yes',
|
|
28
|
+
'personalization',
|
|
29
|
+
'personalized',
|
|
30
|
+
]);
|
|
31
|
+
// ── Cookie parsing ──
|
|
32
|
+
export function parseCookieHeader(value) {
|
|
33
|
+
const cookies = new Map();
|
|
34
|
+
for (const part of (value || '').split(';')) {
|
|
35
|
+
const separator = part.indexOf('=');
|
|
36
|
+
if (separator < 0)
|
|
37
|
+
continue;
|
|
38
|
+
const name = part.slice(0, separator).trim();
|
|
39
|
+
if (name)
|
|
40
|
+
cookies.set(name, part.slice(separator + 1).trim());
|
|
41
|
+
}
|
|
42
|
+
return cookies;
|
|
43
|
+
}
|
|
44
|
+
// ── Consent ──
|
|
45
|
+
export function consentSignals(request, cookies) {
|
|
46
|
+
const privacyOverride = (request.headers.get('x-gradial-privacy-override') || '').trim().toLowerCase();
|
|
47
|
+
const privacyDenied = request.headers.get('sec-gpc')?.trim() === '1'
|
|
48
|
+
|| request.headers.get('dnt')?.trim() === '1'
|
|
49
|
+
|| ['deny', 'opt-out', 'off'].includes(privacyOverride);
|
|
50
|
+
const headerConsent = request.headers.get('x-aci-consent');
|
|
51
|
+
const hasHeaderConsent = trimConsentWhitespace(headerConsent || '') !== '';
|
|
52
|
+
const raw = hasHeaderConsent ? headerConsent : cookies.get('aci_consent') || '';
|
|
53
|
+
const snapshot = parseConsent(raw, !hasHeaderConsent);
|
|
54
|
+
return {
|
|
55
|
+
hasConsent: snapshot !== null,
|
|
56
|
+
consentInvalid: trimConsentWhitespace(raw) !== '' && snapshot === null,
|
|
57
|
+
snapshot,
|
|
58
|
+
allowsIdentityTier: !privacyDenied && Boolean(snapshot?.purposes.some((purpose) => trimConsentWhitespace(purpose).toLowerCase() === 'personalization')),
|
|
59
|
+
privacyDenied,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function parseConsent(raw, decodeCookieValue = true) {
|
|
63
|
+
let decoded = trimConsentWhitespace(raw);
|
|
64
|
+
if (!decoded)
|
|
65
|
+
return null;
|
|
66
|
+
if (decodeCookieValue) {
|
|
67
|
+
try {
|
|
68
|
+
decoded = decodeURIComponent(decoded);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Evaluate the original cookie value.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
decoded = trimConsentWhitespace(decoded);
|
|
75
|
+
if (!decoded || encoder.encode(decoded).byteLength > MAX_CONSENT_ENVELOPE_BYTES)
|
|
76
|
+
return null;
|
|
77
|
+
if (LEGACY_GRANTS.has(decoded.toLowerCase())) {
|
|
78
|
+
return { version: 0, purposes: ['personalization'], grantedAt: '' };
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(decoded);
|
|
82
|
+
const grantedAt = parsed.t === undefined || parsed.t === null ? '' : parsed.t;
|
|
83
|
+
if (!Number.isInteger(parsed.v)
|
|
84
|
+
|| Number(parsed.v) < 0
|
|
85
|
+
|| Number(parsed.v) > MAX_CONSENT_POLICY_VERSION
|
|
86
|
+
|| !Array.isArray(parsed.p)
|
|
87
|
+
|| parsed.p.length > MAX_CONSENT_PURPOSES
|
|
88
|
+
|| !parsed.p.every((item) => typeof item === 'string')
|
|
89
|
+
|| parsed.p.some((item) => encoder.encode(item).byteLength > MAX_CONSENT_PURPOSE_BYTES)
|
|
90
|
+
|| (grantedAt !== '' && (typeof grantedAt !== 'string' || !isRFC3339(grantedAt)))
|
|
91
|
+
|| parsed.t === ''
|
|
92
|
+
|| (parsed.src !== undefined && parsed.src !== null && typeof parsed.src !== 'string')
|
|
93
|
+
|| (typeof parsed.src === 'string' && encoder.encode(parsed.src).byteLength > MAX_CONSENT_SOURCE_BYTES))
|
|
94
|
+
return null;
|
|
95
|
+
return {
|
|
96
|
+
version: Number(parsed.v),
|
|
97
|
+
purposes: parsed.p,
|
|
98
|
+
grantedAt: grantedAt,
|
|
99
|
+
...(typeof parsed.src === 'string' ? { source: parsed.src } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function trimConsentWhitespace(value) {
|
|
107
|
+
return value.replace(CONSENT_EDGE_WHITESPACE, '');
|
|
108
|
+
}
|
|
109
|
+
// ── Identity cookie (aci_id) ──
|
|
110
|
+
export async function verifyIdentityCookie(value, keys) {
|
|
111
|
+
const parts = value.trim().split('.');
|
|
112
|
+
if (parts.length !== 3 || parts.some((part) => part === ''))
|
|
113
|
+
return null;
|
|
114
|
+
const [rawID, kid, signature] = parts;
|
|
115
|
+
const key = [keys.current, keys.previous].find((candidate) => candidate?.kid === kid);
|
|
116
|
+
if (!key)
|
|
117
|
+
return null;
|
|
118
|
+
return await verifyHMAC(`${rawID}.${kid}`, signature, key.secret) ? rawID : null;
|
|
119
|
+
}
|
|
120
|
+
export async function signIdentity(rawID, key) {
|
|
121
|
+
const signature = await hmacSHA256(`${rawID}.${key.kid}`, key.secret);
|
|
122
|
+
return `${rawID}.${key.kid}.${signature}`;
|
|
123
|
+
}
|
|
124
|
+
export function randomIdentity(random) {
|
|
125
|
+
const bytes = random ? random(16) : crypto.getRandomValues(new Uint8Array(16));
|
|
126
|
+
return base64URLEncode(bytes);
|
|
127
|
+
}
|
|
128
|
+
export async function identityHashHex(namespace, value) {
|
|
129
|
+
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(`${namespace.trim().toLowerCase()}\x00${value.trim()}`));
|
|
130
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
131
|
+
}
|
|
132
|
+
export async function sha256Hex(value) {
|
|
133
|
+
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(value));
|
|
134
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
135
|
+
}
|
|
136
|
+
// ── Claims cookie (aci_claims) ──
|
|
137
|
+
export async function verifyClaims(value, keys, now) {
|
|
138
|
+
const parts = value.trim().split('.');
|
|
139
|
+
if (parts.length !== 4 || parts[0] !== 'v1')
|
|
140
|
+
return null;
|
|
141
|
+
const [, kid, body, signature] = parts;
|
|
142
|
+
const secret = keys[kid];
|
|
143
|
+
if (!secret || !await verifyHMAC(`v1.${kid}.${body}`, signature, secret))
|
|
144
|
+
return null;
|
|
145
|
+
try {
|
|
146
|
+
const payload = JSON.parse(decoder.decode(base64URLDecode(body)));
|
|
147
|
+
if (!Number.isFinite(payload.exp) || Number(payload.exp) <= Math.floor(now.getTime() / 1000))
|
|
148
|
+
return null;
|
|
149
|
+
return payload;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// ── Cookie mutation helpers ──
|
|
156
|
+
export function cookieMutation(name, value, maxAge, now, secure, httpOnly) {
|
|
157
|
+
return {
|
|
158
|
+
name,
|
|
159
|
+
value,
|
|
160
|
+
options: { path: '/', httpOnly, secure, sameSite: 'lax', maxAge, expires: new Date(now.getTime() + maxAge * 1000) },
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
export function expireCookie(name, secure) {
|
|
164
|
+
return { name, value: '', options: { path: '/', httpOnly: true, secure, sameSite: 'lax', maxAge: 0, expires: new Date(0) } };
|
|
165
|
+
}
|
|
166
|
+
// ── Internal crypto helpers ──
|
|
167
|
+
async function verifyHMAC(value, signature, secret) {
|
|
168
|
+
try {
|
|
169
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
170
|
+
return await crypto.subtle.verify('HMAC', key, base64URLDecode(signature), encoder.encode(value));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function hmacSHA256(value, secret) {
|
|
177
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
178
|
+
return base64URLEncode(new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(value))));
|
|
179
|
+
}
|
|
180
|
+
function base64URLEncode(bytes) {
|
|
181
|
+
let binary = '';
|
|
182
|
+
for (const byte of bytes)
|
|
183
|
+
binary += String.fromCharCode(byte);
|
|
184
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
185
|
+
}
|
|
186
|
+
function base64URLDecode(value) {
|
|
187
|
+
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
|
|
188
|
+
const binary = atob(padded);
|
|
189
|
+
const bytes = new Uint8Array(binary.length);
|
|
190
|
+
for (let index = 0; index < binary.length; index += 1)
|
|
191
|
+
bytes[index] = binary.charCodeAt(index);
|
|
192
|
+
return bytes;
|
|
193
|
+
}
|
|
194
|
+
function isRFC3339(value) {
|
|
195
|
+
const match = RFC3339.exec(value);
|
|
196
|
+
if (!match || Number.isNaN(Date.parse(value)))
|
|
197
|
+
return false;
|
|
198
|
+
const year = Number(match[1]);
|
|
199
|
+
const month = Number(match[2]);
|
|
200
|
+
const day = Number(match[3]);
|
|
201
|
+
const hour = Number(match[4]);
|
|
202
|
+
const minute = Number(match[5]);
|
|
203
|
+
const second = Number(match[6]);
|
|
204
|
+
if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)
|
|
205
|
+
|| hour > 23 || minute > 59 || second > 59)
|
|
206
|
+
return false;
|
|
207
|
+
const zone = match[8];
|
|
208
|
+
return zone === 'Z' || (Number(zone.slice(1, 3)) <= 23 && Number(zone.slice(4, 6)) <= 59);
|
|
209
|
+
}
|
|
210
|
+
function daysInMonth(year, month) {
|
|
211
|
+
if (month === 2)
|
|
212
|
+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
|
|
213
|
+
return [4, 6, 9, 11].includes(month) ? 30 : 31;
|
|
214
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Barrel export for internal/domain/edge — shared edge-decisioning semantics.
|
|
2
|
+
// Host adapters import from here; the SDK re-exports for backward compat.
|
|
3
|
+
export * from './types.js';
|
|
4
|
+
export * from './personalization-core.js';
|
|
5
|
+
export * from './identity.js';
|
|
6
|
+
export { resolvePersonalizationRequest, } from './resolve.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PersonalizationRequestContext, PersonalizationClaims, PersonalizationVariant, PersonalizationRoute, PersonalizationExperiment, PersonalizationIdentity, PersonalizationDecision, ClassificationData, Classification, EdgeRouteEntry } from './types.js';
|
|
2
|
+
export declare function selectPersonalizationVariant(request: PersonalizationRequestContext, route: PersonalizationRoute | null, claims: PersonalizationClaims | null, experiments?: Record<string, PersonalizationExperiment>, identity?: PersonalizationIdentity): PersonalizationVariant | null;
|
|
3
|
+
export declare function resolvePersonalizationDecision(request: PersonalizationRequestContext, route: PersonalizationRoute | null, claims: PersonalizationClaims | null, experiments?: Record<string, PersonalizationExperiment>, identity?: PersonalizationIdentity): PersonalizationDecision;
|
|
4
|
+
/**
|
|
5
|
+
* Phase 1: Classify the visitor against all release-level rules.
|
|
6
|
+
*
|
|
7
|
+
* @param ctx The request context (cookies, headers, geo, query)
|
|
8
|
+
* @param data Release-level classification data (audiences, segments, experiments)
|
|
9
|
+
* @param identity Visitor identity (for experiment bucketing). Pass undefined
|
|
10
|
+
* if no identity or privacy denied.
|
|
11
|
+
* @returns Classification — matched audiences, segments, and experiment arm assignments
|
|
12
|
+
*/
|
|
13
|
+
export declare function classify(ctx: PersonalizationRequestContext, data: ClassificationData, identity?: PersonalizationIdentity): Classification;
|
|
14
|
+
/**
|
|
15
|
+
* Phase 2: Decide the winning variant for a specific route.
|
|
16
|
+
*
|
|
17
|
+
* Uses the classification from Phase 1 for cheap set-membership lookups.
|
|
18
|
+
* No rule evaluation — just set membership and priority comparison.
|
|
19
|
+
*
|
|
20
|
+
* @param record The route entry (path, dispatch, variants)
|
|
21
|
+
* @param classification The visitor's classification from Phase 1
|
|
22
|
+
* @returns The decision — winning variant + experiment assignment, or nulls
|
|
23
|
+
*/
|
|
24
|
+
export declare function decide(record: EdgeRouteEntry, classification: Classification, ctx: PersonalizationRequestContext): PersonalizationDecision;
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
// Runtime-neutral personalization selection.
|
|
2
|
-
//
|
|
3
|
-
// Keep this file free of platform APIs
|
|
1
|
+
// Runtime-neutral personalization selection. The backend owns this logic
|
|
2
|
+
// and pushes compiled artifacts to each host (CloudFront Function, Vercel
|
|
3
|
+
// middleware, Cloudflare Worker). Keep this file free of platform APIs.
|
|
4
|
+
//
|
|
5
|
+
// This is the pure decision algorithm — no I/O, no cookies, no identity
|
|
6
|
+
// minting. The caller resolves identity and claims, then calls this.
|
|
7
|
+
import { variantValuesFromKey } from './record/codec.js';
|
|
8
|
+
// Build-time constant. The CloudFront Functions bundle replaces this with
|
|
9
|
+
// true and uses the runtime's built-in crypto module; other host bundles
|
|
10
|
+
// replace it with false and retain the portable implementation.
|
|
4
11
|
export function selectPersonalizationVariant(request, route, claims, experiments, identity) {
|
|
5
12
|
return resolvePersonalizationDecision(request, route, claims, experiments, identity).variant;
|
|
6
13
|
}
|
|
@@ -18,9 +25,11 @@ export function resolvePersonalizationDecision(request, route, claims, experimen
|
|
|
18
25
|
for (let index = 0; index < candidates.length; index++) {
|
|
19
26
|
const item = candidates[index];
|
|
20
27
|
const assignment = assignExperiment(item, identity);
|
|
21
|
-
const candidate = assignment
|
|
28
|
+
const candidate = assignment ? route.variants[assignment.variantKey] : undefined;
|
|
22
29
|
if (!candidate)
|
|
23
30
|
continue;
|
|
31
|
+
if (assignment && !candidate.variantKey)
|
|
32
|
+
candidate.variantKey = assignment.variantKey;
|
|
24
33
|
if (!variant || betterVariant(candidate.priority || 0, candidate.variantKey, variant.priority || 0, variant.variantKey)) {
|
|
25
34
|
variant = candidate;
|
|
26
35
|
experiment = assignment;
|
|
@@ -36,6 +45,8 @@ function selectRuleVariant(request, route, claims, experiments) {
|
|
|
36
45
|
const matches = [];
|
|
37
46
|
for (const key in route.variants) {
|
|
38
47
|
const variant = route.variants[key];
|
|
48
|
+
if (!variant.variantKey)
|
|
49
|
+
variant.variantKey = key;
|
|
39
50
|
const values = variant.variantValues || variantValuesFromKey(key);
|
|
40
51
|
const experimentID = String(values.experiment || '').trim();
|
|
41
52
|
if (experimentID && (!experiments || !experiments[experimentID]
|
|
@@ -53,19 +64,6 @@ function selectRuleVariant(request, route, claims, experiments) {
|
|
|
53
64
|
|| String(left.variantKey || '').localeCompare(String(right.variantKey || '')));
|
|
54
65
|
return matches[0] || null;
|
|
55
66
|
}
|
|
56
|
-
// CloudFront KVS values are capped at 1 KiB. The variant map key is the
|
|
57
|
-
// canonical encoding of its dimensions, so compact route records omit the
|
|
58
|
-
// duplicated variantValues object and recover it at the edge.
|
|
59
|
-
function variantValuesFromKey(key) {
|
|
60
|
-
const values = {};
|
|
61
|
-
const parts = String(key || '').replace(/&/g, ';').split(';');
|
|
62
|
-
for (let index = 0; index < parts.length; index++) {
|
|
63
|
-
const separator = parts[index].indexOf('=');
|
|
64
|
-
if (separator > 0)
|
|
65
|
-
values[parts[index].slice(0, separator)] = parts[index].slice(separator + 1);
|
|
66
|
-
}
|
|
67
|
-
return values;
|
|
68
|
-
}
|
|
69
67
|
function routeHasExperiment(route, experimentID) {
|
|
70
68
|
for (const key in route.variants || {}) {
|
|
71
69
|
const values = route.variants[key].variantValues || variantValuesFromKey(key);
|
|
@@ -87,9 +85,9 @@ function assignExperiment(experiment, identity) {
|
|
|
87
85
|
const arms = experiment.arms || [];
|
|
88
86
|
for (let index = 0; index < arms.length; index++) {
|
|
89
87
|
const arm = arms[index];
|
|
90
|
-
if ((arm.allocationBasisPoints
|
|
88
|
+
if ((arm.allocationBasisPoints ?? 0) <= 0)
|
|
91
89
|
continue;
|
|
92
|
-
cumulative += arm.allocationBasisPoints;
|
|
90
|
+
cumulative += arm.allocationBasisPoints ?? 0;
|
|
93
91
|
if (bucket < cumulative) {
|
|
94
92
|
return {
|
|
95
93
|
experimentId: experiment.id,
|
|
@@ -130,55 +128,58 @@ function requestDimensionsMatch(request, values) {
|
|
|
130
128
|
return true;
|
|
131
129
|
}
|
|
132
130
|
function evaluateRule(request, rule) {
|
|
133
|
-
const all = rule.all || [];
|
|
134
|
-
const any = rule.any || [];
|
|
131
|
+
const all = (rule.all || []);
|
|
132
|
+
const any = (rule.any || []);
|
|
135
133
|
if (all.length + any.length === 0)
|
|
136
134
|
return false;
|
|
137
135
|
return all.every((condition) => evaluateCondition(request, condition))
|
|
138
136
|
&& (any.length === 0 || any.some((condition) => evaluateCondition(request, condition)));
|
|
139
137
|
}
|
|
140
138
|
function evaluateCondition(request, condition) {
|
|
141
|
-
const values = signalValues(request, condition.field);
|
|
139
|
+
const values = signalValues(request, String(condition.field || ''));
|
|
142
140
|
if (condition.operator === 'exists')
|
|
143
141
|
return values.length > 0;
|
|
144
|
-
if (values.length === 0 ||
|
|
142
|
+
if (values.length === 0 || typeof condition.value !== 'string' && typeof condition.value !== 'number' && typeof condition.value !== 'boolean')
|
|
145
143
|
return false;
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
});
|
|
144
|
+
const e = String(condition.value);
|
|
145
|
+
if (condition.operator === 'equals')
|
|
146
|
+
return values.some(v => v === e);
|
|
147
|
+
if (condition.operator === 'contains')
|
|
148
|
+
return values.some(v => v.includes(e));
|
|
149
|
+
if (condition.operator === 'startsWith')
|
|
150
|
+
return values.some(v => v.startsWith(e));
|
|
151
|
+
return false;
|
|
155
152
|
}
|
|
156
153
|
function signalValues(request, field) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
return request.
|
|
163
|
-
if (
|
|
164
|
-
return request.query['utm_' +
|
|
165
|
-
if (
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
return value ? [value] : [];
|
|
154
|
+
const d = field.indexOf('.');
|
|
155
|
+
if (d < 0)
|
|
156
|
+
return field === 'referrer' ? [request.headers.referer || request.headers.referrer].filter(Boolean) : [];
|
|
157
|
+
const p = field.slice(0, d), k = field.slice(d + 1);
|
|
158
|
+
if (p === 'query')
|
|
159
|
+
return request.query[k] || [];
|
|
160
|
+
if (p === 'utm')
|
|
161
|
+
return request.query['utm_' + k] || [];
|
|
162
|
+
if (p === 'header') {
|
|
163
|
+
const v = request.headers[k.toLowerCase()];
|
|
164
|
+
return v ? [v] : [];
|
|
169
165
|
}
|
|
170
|
-
if (
|
|
171
|
-
const
|
|
172
|
-
return
|
|
166
|
+
if (p === 'cookie') {
|
|
167
|
+
const v = request.cookies[k];
|
|
168
|
+
return v ? [v] : [];
|
|
169
|
+
}
|
|
170
|
+
if (p === 'geo') {
|
|
171
|
+
const g = request.signals && request.signals.geo;
|
|
172
|
+
const v = g && g[k.toLowerCase()];
|
|
173
|
+
return v ? [v] : [];
|
|
173
174
|
}
|
|
174
175
|
return [];
|
|
175
176
|
}
|
|
176
|
-
// SHA-256 mirrors experiment
|
|
177
|
-
// interpreted big-endian and reduced modulo 10,000 basis points.
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
function
|
|
177
|
+
// SHA-256 mirrors experiment/assignment.go: the first eight digest bytes are
|
|
178
|
+
// interpreted big-endian and reduced modulo 10,000 basis points. Keep the
|
|
179
|
+
// implementation runtime-neutral: this module is bundled into browser and
|
|
180
|
+
// Worker targets as well as exercised by the Go-facing edge code.
|
|
181
|
+
const sha256Bucket = portableSHA256Bucket;
|
|
182
|
+
function portableSHA256Bucket(value) {
|
|
182
183
|
const bytes = utf8(value);
|
|
183
184
|
const words = new Uint32Array(64);
|
|
184
185
|
const bitLength = bytes.length * 8;
|
|
@@ -186,10 +187,6 @@ function sha256Bucket(value) {
|
|
|
186
187
|
const data = new Uint8Array(paddedLength);
|
|
187
188
|
data.set(bytes);
|
|
188
189
|
data[bytes.length] = 128;
|
|
189
|
-
// The message length is a 64-bit big-endian integer across the final
|
|
190
|
-
// eight bytes. The previous code wrote a single truncated byte into each
|
|
191
|
-
// word's most significant position, corrupting every digest (caught by
|
|
192
|
-
// the assignment parity vectors, spec experiment-variant-caching REQ-9).
|
|
193
190
|
const bitsHigh = Math.floor(bitLength / 0x100000000);
|
|
194
191
|
const bitsLow = bitLength >>> 0;
|
|
195
192
|
data[paddedLength - 8] = (bitsHigh >>> 24) & 255;
|
|
@@ -238,12 +235,6 @@ function sha256Bucket(value) {
|
|
|
238
235
|
g = (g + gg) >>> 0;
|
|
239
236
|
h = (h + hh) >>> 0;
|
|
240
237
|
}
|
|
241
|
-
// Reduce the 64-bit big-endian value modulo 10000 in 32-bit halves:
|
|
242
|
-
// (hi*2^32 + lo) % 10000 == ((hi%10000) * (2^32 % 10000) + lo%10000) % 10000,
|
|
243
|
-
// and 2^32 % 10000 = 7296. Multiplying hi by 2^32 directly exceeds
|
|
244
|
-
// Number.MAX_SAFE_INTEGER and rounds away the low bits — that float error
|
|
245
|
-
// made most JS buckets diverge from assignment.go (caught by the
|
|
246
|
-
// assignment parity vectors, spec experiment-variant-caching REQ-9).
|
|
247
238
|
return (((a >>> 0) % 10000) * 7296 + ((b >>> 0) % 10000)) % 10000;
|
|
248
239
|
}
|
|
249
240
|
function rotr(value, amount) { return (value >>> amount) | (value << (32 - amount)); }
|
|
@@ -266,4 +257,114 @@ function utf8(value) {
|
|
|
266
257
|
}
|
|
267
258
|
return new Uint8Array(out);
|
|
268
259
|
}
|
|
269
|
-
//
|
|
260
|
+
// ── Normalized two-phase model: classify → decide ─────────────────────
|
|
261
|
+
//
|
|
262
|
+
// Phase 1 (classify): evaluate all audience/segment rules page-agnostically
|
|
263
|
+
// and assign experiment arms. Returns a Classification that is used by
|
|
264
|
+
// Phase 2 for cheap set-membership lookups per route.
|
|
265
|
+
//
|
|
266
|
+
// Phase 2 (decide): for a given route record, check which variants match
|
|
267
|
+
// the classification (set membership + priority). No rule evaluation —
|
|
268
|
+
// just "is this audience in matchedAudiences?" and "is this experiment arm
|
|
269
|
+
// the one we assigned?"
|
|
270
|
+
//
|
|
271
|
+
// The per-route record stays small: just variant keys (e.g.
|
|
272
|
+
// "audience=trade") + priorities. Rules live in the classification data,
|
|
273
|
+
// evaluated once per request.
|
|
274
|
+
//
|
|
275
|
+
/**
|
|
276
|
+
* Phase 1: Classify the visitor against all release-level rules.
|
|
277
|
+
*
|
|
278
|
+
* @param ctx The request context (cookies, headers, geo, query)
|
|
279
|
+
* @param data Release-level classification data (audiences, segments, experiments)
|
|
280
|
+
* @param identity Visitor identity (for experiment bucketing). Pass undefined
|
|
281
|
+
* if no identity or privacy denied.
|
|
282
|
+
* @returns Classification — matched audiences, segments, and experiment arm assignments
|
|
283
|
+
*/
|
|
284
|
+
export function classify(ctx, data, identity) {
|
|
285
|
+
// Evaluate audience rules
|
|
286
|
+
const matchedAudiences = [];
|
|
287
|
+
for (const key in data.audiences) {
|
|
288
|
+
const def = data.audiences[key];
|
|
289
|
+
if (def.rule && evaluateRule(ctx, def.rule)) {
|
|
290
|
+
matchedAudiences.push(key);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Evaluate segment rules
|
|
294
|
+
const matchedSegments = [];
|
|
295
|
+
for (const key in data.segments) {
|
|
296
|
+
const def = data.segments[key];
|
|
297
|
+
if (def.rule && evaluateRule(ctx, def.rule)) {
|
|
298
|
+
matchedSegments.push(key);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// Assign experiment arms (only for identified visitors)
|
|
302
|
+
const experimentArms = {};
|
|
303
|
+
if (identity && identity.usable) {
|
|
304
|
+
for (const expId in data.experiments) {
|
|
305
|
+
const exp = data.experiments[expId];
|
|
306
|
+
if (exp.status !== 'running')
|
|
307
|
+
continue;
|
|
308
|
+
if (exp.kind !== 'ab' && exp.kind !== 'abn')
|
|
309
|
+
continue;
|
|
310
|
+
const assignment = assignExperiment(exp, identity);
|
|
311
|
+
if (assignment) {
|
|
312
|
+
experimentArms[expId] = assignment;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { matchedAudiences, matchedSegments, experimentArms };
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Phase 2: Decide the winning variant for a specific route.
|
|
320
|
+
*
|
|
321
|
+
* Uses the classification from Phase 1 for cheap set-membership lookups.
|
|
322
|
+
* No rule evaluation — just set membership and priority comparison.
|
|
323
|
+
*
|
|
324
|
+
* @param record The route entry (path, dispatch, variants)
|
|
325
|
+
* @param classification The visitor's classification from Phase 1
|
|
326
|
+
* @returns The decision — winning variant + experiment assignment, or nulls
|
|
327
|
+
*/
|
|
328
|
+
export function decide(record, classification, ctx) {
|
|
329
|
+
if (!record.variants)
|
|
330
|
+
return { variant: null, experiment: null };
|
|
331
|
+
let bestVariant = null;
|
|
332
|
+
let bestExperiment = null;
|
|
333
|
+
for (const key in record.variants) {
|
|
334
|
+
const variant = record.variants[key];
|
|
335
|
+
if (!variant.variantKey)
|
|
336
|
+
variant.variantKey = key;
|
|
337
|
+
const values = variant.variantValues || variantValuesFromKey(key);
|
|
338
|
+
// Check if this variant references a paused experiment — skip if so
|
|
339
|
+
const experimentID = String(values.experiment || '').trim();
|
|
340
|
+
if (experimentID && !classification.experimentArms[experimentID])
|
|
341
|
+
continue;
|
|
342
|
+
let matched = false;
|
|
343
|
+
// Audience membership
|
|
344
|
+
if (values.audience && classification.matchedAudiences.includes(values.audience)) {
|
|
345
|
+
matched = true;
|
|
346
|
+
}
|
|
347
|
+
// Segment membership
|
|
348
|
+
if (!matched && values.segment && classification.matchedSegments.includes(values.segment)) {
|
|
349
|
+
matched = true;
|
|
350
|
+
}
|
|
351
|
+
// Experiment arm assignment
|
|
352
|
+
if (!matched && experimentID) {
|
|
353
|
+
const arm = classification.experimentArms[experimentID];
|
|
354
|
+
if (arm && arm.variantKey === variant.variantKey) {
|
|
355
|
+
matched = true;
|
|
356
|
+
bestExperiment = arm;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (matched) {
|
|
360
|
+
if (!bestVariant || betterVariant(variant.priority || 0, variant.variantKey, bestVariant.priority || 0, bestVariant.variantKey)) {
|
|
361
|
+
bestVariant = variant;
|
|
362
|
+
// Only set experiment if this variant's experiment arm won
|
|
363
|
+
if (!experimentID || !classification.experimentArms[experimentID]) {
|
|
364
|
+
bestExperiment = null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { variant: bestVariant, experiment: bestExperiment };
|
|
370
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { EdgeRouteEntry, ClassificationData } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Unique sentinel object returned by expand functions on failure.
|
|
4
|
+
* Callers check `result === INVALID`. Never throws.
|
|
5
|
+
*/
|
|
6
|
+
export declare const INVALID: unique symbol;
|
|
7
|
+
export type Invalid = typeof INVALID;
|
|
8
|
+
interface CompactDispatch {
|
|
9
|
+
k: 's';
|
|
10
|
+
ap: string;
|
|
11
|
+
}
|
|
12
|
+
interface CompactCondition {
|
|
13
|
+
f: string;
|
|
14
|
+
o: string;
|
|
15
|
+
w?: unknown;
|
|
16
|
+
}
|
|
17
|
+
interface CompactRule {
|
|
18
|
+
a?: CompactCondition[];
|
|
19
|
+
n?: CompactCondition[];
|
|
20
|
+
}
|
|
21
|
+
interface CompactVariant {
|
|
22
|
+
pr?: number;
|
|
23
|
+
r?: CompactRule;
|
|
24
|
+
d?: CompactDispatch;
|
|
25
|
+
vk?: string;
|
|
26
|
+
vv?: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
export interface CompactRouteRecord {
|
|
29
|
+
s?: number;
|
|
30
|
+
p: string;
|
|
31
|
+
d: CompactDispatch;
|
|
32
|
+
v?: Record<string, CompactVariant>;
|
|
33
|
+
x?: string[];
|
|
34
|
+
}
|
|
35
|
+
export declare function projectRouteRecord(entry: EdgeRouteEntry): CompactRouteRecord;
|
|
36
|
+
export declare function expandRouteRecord(raw: unknown): EdgeRouteEntry | Invalid;
|
|
37
|
+
export interface CompactClassificationData {
|
|
38
|
+
au?: Record<string, {
|
|
39
|
+
r?: CompactRule;
|
|
40
|
+
}>;
|
|
41
|
+
sg?: Record<string, {
|
|
42
|
+
r?: CompactRule;
|
|
43
|
+
}>;
|
|
44
|
+
x?: Record<string, CompactExperiment>;
|
|
45
|
+
}
|
|
46
|
+
interface CompactExperimentArm {
|
|
47
|
+
id: string;
|
|
48
|
+
ab?: number;
|
|
49
|
+
vk?: string;
|
|
50
|
+
ro?: string;
|
|
51
|
+
}
|
|
52
|
+
interface CompactExperiment {
|
|
53
|
+
id: string;
|
|
54
|
+
st?: string;
|
|
55
|
+
k?: string;
|
|
56
|
+
av?: string;
|
|
57
|
+
sl?: string;
|
|
58
|
+
pr?: number;
|
|
59
|
+
ar?: CompactExperimentArm[];
|
|
60
|
+
dn?: string;
|
|
61
|
+
pg?: string;
|
|
62
|
+
gl?: string[];
|
|
63
|
+
}
|
|
64
|
+
export declare function projectClassificationData(data: ClassificationData): CompactClassificationData;
|
|
65
|
+
export declare function expandClassificationData(raw: unknown): ClassificationData | Invalid;
|
|
66
|
+
export declare const MARKER = "{\"s\":1,\"e\":1}";
|
|
67
|
+
export declare function isMarker(raw: string): boolean;
|
|
68
|
+
export declare function canonicalSerialize(value: unknown): string;
|
|
69
|
+
export declare function variantValuesFromKey(key: string): Record<string, string>;
|
|
70
|
+
export declare function pathHash16(path: string): string;
|
|
71
|
+
export {};
|