perspectapi-ts-sdk 6.1.2 → 6.3.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.
- package/dist/index.d.mts +80 -1
- package/dist/index.d.ts +80 -1
- package/dist/index.js +231 -9
- package/dist/index.mjs +225 -8
- package/package.json +1 -1
- package/src/ab/ab-client.ts +262 -0
- package/src/ab/bucketing.ts +56 -0
- package/src/index.ts +14 -1
- package/src/types/index.ts +14 -0
- package/src/utils/http-client.ts +8 -7
- package/src/v2/client/collections-client.ts +29 -5
package/dist/index.mjs
CHANGED
|
@@ -8,6 +8,20 @@ function warnV1Deprecated() {
|
|
|
8
8
|
console.warn(`[perspectapi-ts-sdk] ${V1_DEPRECATION_NOTICE}`);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// src/types/index.ts
|
|
12
|
+
var PerspectApiError = class extends Error {
|
|
13
|
+
code;
|
|
14
|
+
status;
|
|
15
|
+
details;
|
|
16
|
+
constructor({ message, code, status, details }) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "PerspectApiError";
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.details = details;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
11
25
|
// src/utils/http-client.ts
|
|
12
26
|
var HttpClient = class {
|
|
13
27
|
baseUrl;
|
|
@@ -211,12 +225,12 @@ var HttpClient = class {
|
|
|
211
225
|
}
|
|
212
226
|
if (!response.ok) {
|
|
213
227
|
console.error(`[HTTP Client - Response] Error response received`);
|
|
214
|
-
const error = {
|
|
228
|
+
const error = new PerspectApiError({
|
|
215
229
|
message: data?.error || data?.message || `HTTP ${response.status}: ${response.statusText}`,
|
|
216
230
|
status: response.status,
|
|
217
231
|
code: data?.code,
|
|
218
232
|
details: data
|
|
219
|
-
};
|
|
233
|
+
});
|
|
220
234
|
console.error(`[HTTP Client - Response] Throwing error:`, error);
|
|
221
235
|
throw error;
|
|
222
236
|
}
|
|
@@ -825,13 +839,29 @@ var CollectionsV2Client = class extends BaseV2Client {
|
|
|
825
839
|
return this.getOne(this.sitePath(siteName, "collections", id));
|
|
826
840
|
}
|
|
827
841
|
async create(siteName, data) {
|
|
828
|
-
|
|
842
|
+
const result = await this.post(this.sitePath(siteName, "collections"), data);
|
|
843
|
+
await this.invalidateCache({ keys: [this.sitePath(siteName, "collections")] });
|
|
844
|
+
return result;
|
|
829
845
|
}
|
|
830
846
|
async update(siteName, id, data) {
|
|
831
|
-
|
|
847
|
+
const result = await this.patchOne(this.sitePath(siteName, "collections", id), data);
|
|
848
|
+
await this.invalidateCache({
|
|
849
|
+
keys: [
|
|
850
|
+
this.sitePath(siteName, "collections"),
|
|
851
|
+
this.sitePath(siteName, "collections", id)
|
|
852
|
+
]
|
|
853
|
+
});
|
|
854
|
+
return result;
|
|
832
855
|
}
|
|
833
856
|
async del(siteName, id) {
|
|
834
|
-
|
|
857
|
+
const result = await this.deleteOne(this.sitePath(siteName, "collections", id));
|
|
858
|
+
await this.invalidateCache({
|
|
859
|
+
keys: [
|
|
860
|
+
this.sitePath(siteName, "collections"),
|
|
861
|
+
this.sitePath(siteName, "collections", id)
|
|
862
|
+
]
|
|
863
|
+
});
|
|
864
|
+
return result;
|
|
835
865
|
}
|
|
836
866
|
// --- Items ---
|
|
837
867
|
async listItems(siteName, collectionId) {
|
|
@@ -840,15 +870,23 @@ var CollectionsV2Client = class extends BaseV2Client {
|
|
|
840
870
|
);
|
|
841
871
|
}
|
|
842
872
|
async addItem(siteName, collectionId, data) {
|
|
843
|
-
|
|
873
|
+
const result = await this.post(
|
|
844
874
|
this.sitePath(siteName, "collections", `${collectionId}/items`),
|
|
845
875
|
data
|
|
846
876
|
);
|
|
877
|
+
await this.invalidateCache({
|
|
878
|
+
keys: [this.sitePath(siteName, "collections", `${collectionId}/items`)]
|
|
879
|
+
});
|
|
880
|
+
return result;
|
|
847
881
|
}
|
|
848
882
|
async removeItem(siteName, collectionId, itemId) {
|
|
849
|
-
|
|
883
|
+
const result = await this.deleteOne(
|
|
850
884
|
this.sitePath(siteName, "collections", `${collectionId}/items/${itemId}`)
|
|
851
885
|
);
|
|
886
|
+
await this.invalidateCache({
|
|
887
|
+
keys: [this.sitePath(siteName, "collections", `${collectionId}/items`)]
|
|
888
|
+
});
|
|
889
|
+
return result;
|
|
852
890
|
}
|
|
853
891
|
};
|
|
854
892
|
|
|
@@ -4068,6 +4106,180 @@ var CloudflareKVCacheAdapter = class {
|
|
|
4068
4106
|
}
|
|
4069
4107
|
};
|
|
4070
4108
|
|
|
4109
|
+
// src/cache/in-memory-adapter.ts
|
|
4110
|
+
var InMemoryCacheAdapter = class {
|
|
4111
|
+
store = /* @__PURE__ */ new Map();
|
|
4112
|
+
async get(key) {
|
|
4113
|
+
const entry = this.store.get(key);
|
|
4114
|
+
if (!entry) {
|
|
4115
|
+
return void 0;
|
|
4116
|
+
}
|
|
4117
|
+
if (entry.expiresAt && entry.expiresAt <= Date.now()) {
|
|
4118
|
+
this.store.delete(key);
|
|
4119
|
+
return void 0;
|
|
4120
|
+
}
|
|
4121
|
+
return entry.value;
|
|
4122
|
+
}
|
|
4123
|
+
async set(key, value, options) {
|
|
4124
|
+
const expiresAt = options?.ttlSeconds && options.ttlSeconds > 0 ? Date.now() + options.ttlSeconds * 1e3 : void 0;
|
|
4125
|
+
this.store.set(key, { value, expiresAt });
|
|
4126
|
+
}
|
|
4127
|
+
async delete(key) {
|
|
4128
|
+
this.store.delete(key);
|
|
4129
|
+
}
|
|
4130
|
+
async deleteMany(keys) {
|
|
4131
|
+
keys.forEach((key) => this.store.delete(key));
|
|
4132
|
+
}
|
|
4133
|
+
async clear() {
|
|
4134
|
+
this.store.clear();
|
|
4135
|
+
}
|
|
4136
|
+
};
|
|
4137
|
+
|
|
4138
|
+
// src/ab/bucketing.ts
|
|
4139
|
+
var BUCKET_SPACE = 1e4;
|
|
4140
|
+
function bucketFor(visitorId, flagKey, version) {
|
|
4141
|
+
const input = `${visitorId}|${flagKey}|${version}`;
|
|
4142
|
+
const bytes = new TextEncoder().encode(input);
|
|
4143
|
+
let hash = 2166136261;
|
|
4144
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
4145
|
+
hash ^= bytes[i];
|
|
4146
|
+
hash = Math.imul(hash, 16777619);
|
|
4147
|
+
}
|
|
4148
|
+
return (hash >>> 0) % BUCKET_SPACE;
|
|
4149
|
+
}
|
|
4150
|
+
function variantForBucket(bucket, variants, trafficAllocationBp) {
|
|
4151
|
+
if (bucket >= trafficAllocationBp) return null;
|
|
4152
|
+
const sorted = [...variants].sort((a, b) => a.key < b.key ? -1 : 1);
|
|
4153
|
+
const totalWeight = sorted.reduce((acc, v) => acc + v.weightBp, 0);
|
|
4154
|
+
if (totalWeight <= 0) return null;
|
|
4155
|
+
const scaled = Math.floor(bucket * totalWeight / trafficAllocationBp);
|
|
4156
|
+
let cursor = 0;
|
|
4157
|
+
for (const v of sorted) {
|
|
4158
|
+
cursor += v.weightBp;
|
|
4159
|
+
if (scaled < cursor) return v.key;
|
|
4160
|
+
}
|
|
4161
|
+
return sorted[sorted.length - 1].key;
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
// src/ab/ab-client.ts
|
|
4165
|
+
var CONFIG_TTL_SECONDS = 3600;
|
|
4166
|
+
var VID_COOKIE = "perspect_vid";
|
|
4167
|
+
var VID_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 * 2;
|
|
4168
|
+
var BASE_URL_DEFAULT = "https://api.perspect.com";
|
|
4169
|
+
function createPerspectAb(options) {
|
|
4170
|
+
const { apiKey, siteName, ctx } = options;
|
|
4171
|
+
const baseUrl = (options.baseUrl ?? BASE_URL_DEFAULT).replace(/\/$/, "");
|
|
4172
|
+
return {
|
|
4173
|
+
async forRequest(request) {
|
|
4174
|
+
const { visitorId, mintedNew } = readOrMintVid(request);
|
|
4175
|
+
const responseHeaders = new Headers();
|
|
4176
|
+
if (mintedNew) {
|
|
4177
|
+
responseHeaders.append(
|
|
4178
|
+
"Set-Cookie",
|
|
4179
|
+
`${VID_COOKIE}=${visitorId}; Path=/; Max-Age=${VID_COOKIE_MAX_AGE}; SameSite=Lax; Secure; HttpOnly`
|
|
4180
|
+
);
|
|
4181
|
+
}
|
|
4182
|
+
const config = await getConfig(options.cache, apiKey, siteName, baseUrl);
|
|
4183
|
+
return { ab: buildAbClient(apiKey, siteName, baseUrl, ctx, config, visitorId), responseHeaders };
|
|
4184
|
+
},
|
|
4185
|
+
async refreshConfig() {
|
|
4186
|
+
await fetchAndStoreConfig(options.cache, apiKey, siteName, baseUrl);
|
|
4187
|
+
}
|
|
4188
|
+
};
|
|
4189
|
+
}
|
|
4190
|
+
function buildAbClient(apiKey, siteName, baseUrl, ctx, config, visitorId) {
|
|
4191
|
+
return {
|
|
4192
|
+
async getVariant(flagKey) {
|
|
4193
|
+
const flag = config.flags[flagKey];
|
|
4194
|
+
if (!flag || flag.status !== "running") {
|
|
4195
|
+
return { variant: flag?.defaultVariant ?? null, enrolled: false, config: null };
|
|
4196
|
+
}
|
|
4197
|
+
const bucket = bucketFor(visitorId, flagKey, flag.currentVersion);
|
|
4198
|
+
const assigned = variantForBucket(bucket, flag.variants, flag.trafficAllocationBp);
|
|
4199
|
+
if (assigned === null) {
|
|
4200
|
+
return { variant: flag.defaultVariant, enrolled: false, config: null };
|
|
4201
|
+
}
|
|
4202
|
+
const variantCfg = flag.variants.find((v) => v.key === assigned)?.config ?? null;
|
|
4203
|
+
emit(apiKey, baseUrl, ctx, {
|
|
4204
|
+
site: siteName,
|
|
4205
|
+
kind: "exposure",
|
|
4206
|
+
flag: flagKey,
|
|
4207
|
+
version: flag.currentVersion,
|
|
4208
|
+
variant: assigned,
|
|
4209
|
+
visitorId,
|
|
4210
|
+
dedup: await dedupFor(visitorId, flagKey, flag.currentVersion, assigned, "exposure"),
|
|
4211
|
+
ts: Math.floor(Date.now() / 1e3)
|
|
4212
|
+
});
|
|
4213
|
+
return { variant: assigned, enrolled: true, config: variantCfg };
|
|
4214
|
+
},
|
|
4215
|
+
async track(event, properties) {
|
|
4216
|
+
for (const [flagKey, flag] of Object.entries(config.flags)) {
|
|
4217
|
+
if (flag.status !== "running") continue;
|
|
4218
|
+
const goals = flag.goalsByVersion[String(flag.currentVersion)] ?? [];
|
|
4219
|
+
if (!goals.includes(event)) continue;
|
|
4220
|
+
const bucket = bucketFor(visitorId, flagKey, flag.currentVersion);
|
|
4221
|
+
const assigned = variantForBucket(bucket, flag.variants, flag.trafficAllocationBp);
|
|
4222
|
+
if (assigned === null) continue;
|
|
4223
|
+
emit(apiKey, baseUrl, ctx, {
|
|
4224
|
+
site: siteName,
|
|
4225
|
+
kind: "conversion",
|
|
4226
|
+
flag: flagKey,
|
|
4227
|
+
version: flag.currentVersion,
|
|
4228
|
+
variant: assigned,
|
|
4229
|
+
visitorId,
|
|
4230
|
+
event,
|
|
4231
|
+
dedup: await dedupFor(visitorId, flagKey, flag.currentVersion, assigned, event),
|
|
4232
|
+
ts: Math.floor(Date.now() / 1e3),
|
|
4233
|
+
properties
|
|
4234
|
+
});
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
};
|
|
4238
|
+
}
|
|
4239
|
+
function configCacheKey(siteName) {
|
|
4240
|
+
return `ab:config:${siteName}`;
|
|
4241
|
+
}
|
|
4242
|
+
async function getConfig(cache, apiKey, siteName, baseUrl) {
|
|
4243
|
+
if (cache) {
|
|
4244
|
+
const raw = await cache.get(configCacheKey(siteName));
|
|
4245
|
+
if (raw) return JSON.parse(raw);
|
|
4246
|
+
}
|
|
4247
|
+
return fetchAndStoreConfig(cache, apiKey, siteName, baseUrl);
|
|
4248
|
+
}
|
|
4249
|
+
async function fetchAndStoreConfig(cache, apiKey, siteName, baseUrl) {
|
|
4250
|
+
const res = await fetch(`${baseUrl}/_ab/config?site=${encodeURIComponent(siteName)}`, {
|
|
4251
|
+
headers: { "x-api-key": apiKey }
|
|
4252
|
+
});
|
|
4253
|
+
if (!res.ok) return { schemaVersion: 1, flags: {} };
|
|
4254
|
+
const config = await res.json();
|
|
4255
|
+
if (cache) {
|
|
4256
|
+
await cache.set(configCacheKey(siteName), JSON.stringify(config), { ttlSeconds: CONFIG_TTL_SECONDS });
|
|
4257
|
+
}
|
|
4258
|
+
return config;
|
|
4259
|
+
}
|
|
4260
|
+
function emit(apiKey, baseUrl, ctx, body) {
|
|
4261
|
+
const task = fetch(`${baseUrl}/_ab/event`, {
|
|
4262
|
+
method: "POST",
|
|
4263
|
+
headers: { "content-type": "application/json", "x-api-key": apiKey },
|
|
4264
|
+
body: JSON.stringify(body)
|
|
4265
|
+
}).then(() => void 0).catch(() => void 0);
|
|
4266
|
+
if (ctx) ctx.waitUntil(task);
|
|
4267
|
+
}
|
|
4268
|
+
function readOrMintVid(request) {
|
|
4269
|
+
const cookie = request.headers.get("cookie") ?? "";
|
|
4270
|
+
const match = cookie.match(/(?:^|; )perspect_vid=([^;]+)/);
|
|
4271
|
+
if (match) return { visitorId: decodeURIComponent(match[1]), mintedNew: false };
|
|
4272
|
+
return { visitorId: crypto.randomUUID().replace(/-/g, ""), mintedNew: true };
|
|
4273
|
+
}
|
|
4274
|
+
async function dedupFor(visitorId, flagKey, version, variant, event) {
|
|
4275
|
+
const input = `${visitorId}|${flagKey}|${version}|${variant}|${event}`;
|
|
4276
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
|
|
4277
|
+
const bytes = new Uint8Array(digest);
|
|
4278
|
+
let hex = "";
|
|
4279
|
+
for (let i = 0; i < 16; i++) hex += bytes[i].toString(16).padStart(2, "0");
|
|
4280
|
+
return hex;
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4071
4283
|
// src/utils/image-transform.ts
|
|
4072
4284
|
var DEFAULT_IMAGE_SIZES = {
|
|
4073
4285
|
thumbnail: {
|
|
@@ -4738,11 +4950,13 @@ export {
|
|
|
4738
4950
|
ContentClient,
|
|
4739
4951
|
DEFAULT_IMAGE_SIZES,
|
|
4740
4952
|
HttpClient,
|
|
4953
|
+
InMemoryCacheAdapter,
|
|
4741
4954
|
NewsletterClient,
|
|
4742
4955
|
NewsletterManagementClient,
|
|
4743
4956
|
NoopCacheAdapter,
|
|
4744
4957
|
OrganizationsClient,
|
|
4745
4958
|
PerspectApiClient,
|
|
4959
|
+
PerspectApiError,
|
|
4746
4960
|
PerspectApiV2Client,
|
|
4747
4961
|
PerspectV2Error,
|
|
4748
4962
|
ProductsClient,
|
|
@@ -4751,9 +4965,11 @@ export {
|
|
|
4751
4965
|
V1_DEPRECATION_NOTICE,
|
|
4752
4966
|
V1_SUNSET_DATE,
|
|
4753
4967
|
WebhooksClient,
|
|
4968
|
+
bucketFor,
|
|
4754
4969
|
buildImageUrl,
|
|
4755
4970
|
createApiError,
|
|
4756
4971
|
createCheckoutSession,
|
|
4972
|
+
createPerspectAb,
|
|
4757
4973
|
createPerspectApiClient,
|
|
4758
4974
|
createPerspectApiV2Client,
|
|
4759
4975
|
perspect_api_client_default as default,
|
|
@@ -4771,5 +4987,6 @@ export {
|
|
|
4771
4987
|
loadProducts,
|
|
4772
4988
|
transformContent,
|
|
4773
4989
|
transformMediaItem,
|
|
4774
|
-
transformProduct
|
|
4990
|
+
transformProduct,
|
|
4991
|
+
variantForBucket
|
|
4775
4992
|
};
|
package/package.json
CHANGED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { bucketFor, variantForBucket } from './bucketing';
|
|
2
|
+
import type { CacheAdapter } from '../cache/types';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Public types
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export interface CreatePerspectAbOptions {
|
|
9
|
+
apiKey: string;
|
|
10
|
+
siteName: string;
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
/** Pluggable cache adapter — CloudflareKVCacheAdapter, InMemoryCacheAdapter, or NoopCacheAdapter. */
|
|
13
|
+
cache?: CacheAdapter;
|
|
14
|
+
/** ExecutionContext (CF Workers) or any object with waitUntil — enables background tasks. */
|
|
15
|
+
ctx?: { waitUntil(p: Promise<unknown>): void };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AbForRequestResult {
|
|
19
|
+
ab: AbClient;
|
|
20
|
+
/** Merge into the outgoing response — may carry a freshly minted perspect_vid cookie. */
|
|
21
|
+
responseHeaders: Headers;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AbVariantResult {
|
|
25
|
+
variant: string | null;
|
|
26
|
+
enrolled: boolean;
|
|
27
|
+
config: Record<string, unknown> | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AbClient {
|
|
31
|
+
getVariant(flagKey: string): Promise<AbVariantResult>;
|
|
32
|
+
track(event: string, properties?: Record<string, unknown>): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PerspectAb {
|
|
36
|
+
forRequest(request: Request): Promise<AbForRequestResult>;
|
|
37
|
+
refreshConfig(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Internal config types
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
interface AbVariantCfg {
|
|
45
|
+
key: string;
|
|
46
|
+
weightBp: number;
|
|
47
|
+
config: Record<string, unknown> | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface AbFlagCfg {
|
|
51
|
+
currentVersion: number;
|
|
52
|
+
status: 'draft' | 'running' | 'paused' | 'stopped';
|
|
53
|
+
variants: AbVariantCfg[];
|
|
54
|
+
trafficAllocationBp: number;
|
|
55
|
+
defaultVariant: string;
|
|
56
|
+
goalsByVersion: Record<string, string[]>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface AbConfigBlob {
|
|
60
|
+
schemaVersion: 1;
|
|
61
|
+
flags: Record<string, AbFlagCfg>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Constants
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
const CONFIG_TTL_SECONDS = 3_600;
|
|
69
|
+
const VID_COOKIE = 'perspect_vid';
|
|
70
|
+
const VID_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 * 2;
|
|
71
|
+
const BASE_URL_DEFAULT = 'https://api.perspect.com';
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Public entry point
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
export function createPerspectAb(options: CreatePerspectAbOptions): PerspectAb {
|
|
78
|
+
const { apiKey, siteName, ctx } = options;
|
|
79
|
+
const baseUrl = (options.baseUrl ?? BASE_URL_DEFAULT).replace(/\/$/, '');
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
async forRequest(request: Request): Promise<AbForRequestResult> {
|
|
83
|
+
const { visitorId, mintedNew } = readOrMintVid(request);
|
|
84
|
+
const responseHeaders = new Headers();
|
|
85
|
+
if (mintedNew) {
|
|
86
|
+
responseHeaders.append(
|
|
87
|
+
'Set-Cookie',
|
|
88
|
+
`${VID_COOKIE}=${visitorId}; Path=/; Max-Age=${VID_COOKIE_MAX_AGE}; SameSite=Lax; Secure; HttpOnly`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const config = await getConfig(options.cache, apiKey, siteName, baseUrl);
|
|
92
|
+
return { ab: buildAbClient(apiKey, siteName, baseUrl, ctx, config, visitorId), responseHeaders };
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async refreshConfig(): Promise<void> {
|
|
96
|
+
await fetchAndStoreConfig(options.cache, apiKey, siteName, baseUrl);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// AB client
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
function buildAbClient(
|
|
106
|
+
apiKey: string,
|
|
107
|
+
siteName: string,
|
|
108
|
+
baseUrl: string,
|
|
109
|
+
ctx: { waitUntil(p: Promise<unknown>): void } | undefined,
|
|
110
|
+
config: AbConfigBlob,
|
|
111
|
+
visitorId: string,
|
|
112
|
+
): AbClient {
|
|
113
|
+
return {
|
|
114
|
+
async getVariant(flagKey: string): Promise<AbVariantResult> {
|
|
115
|
+
const flag = config.flags[flagKey];
|
|
116
|
+
if (!flag || flag.status !== 'running') {
|
|
117
|
+
return { variant: flag?.defaultVariant ?? null, enrolled: false, config: null };
|
|
118
|
+
}
|
|
119
|
+
const bucket = bucketFor(visitorId, flagKey, flag.currentVersion);
|
|
120
|
+
const assigned = variantForBucket(bucket, flag.variants, flag.trafficAllocationBp);
|
|
121
|
+
if (assigned === null) {
|
|
122
|
+
return { variant: flag.defaultVariant, enrolled: false, config: null };
|
|
123
|
+
}
|
|
124
|
+
const variantCfg = flag.variants.find((v) => v.key === assigned)?.config ?? null;
|
|
125
|
+
emit(apiKey, baseUrl, ctx, {
|
|
126
|
+
site: siteName,
|
|
127
|
+
kind: 'exposure',
|
|
128
|
+
flag: flagKey,
|
|
129
|
+
version: flag.currentVersion,
|
|
130
|
+
variant: assigned,
|
|
131
|
+
visitorId,
|
|
132
|
+
dedup: await dedupFor(visitorId, flagKey, flag.currentVersion, assigned, 'exposure'),
|
|
133
|
+
ts: Math.floor(Date.now() / 1000),
|
|
134
|
+
});
|
|
135
|
+
return { variant: assigned, enrolled: true, config: variantCfg };
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async track(event: string, properties?: Record<string, unknown>): Promise<void> {
|
|
139
|
+
for (const [flagKey, flag] of Object.entries(config.flags)) {
|
|
140
|
+
if (flag.status !== 'running') continue;
|
|
141
|
+
const goals = flag.goalsByVersion[String(flag.currentVersion)] ?? [];
|
|
142
|
+
if (!goals.includes(event)) continue;
|
|
143
|
+
const bucket = bucketFor(visitorId, flagKey, flag.currentVersion);
|
|
144
|
+
const assigned = variantForBucket(bucket, flag.variants, flag.trafficAllocationBp);
|
|
145
|
+
if (assigned === null) continue;
|
|
146
|
+
emit(apiKey, baseUrl, ctx, {
|
|
147
|
+
site: siteName,
|
|
148
|
+
kind: 'conversion',
|
|
149
|
+
flag: flagKey,
|
|
150
|
+
version: flag.currentVersion,
|
|
151
|
+
variant: assigned,
|
|
152
|
+
visitorId,
|
|
153
|
+
event,
|
|
154
|
+
dedup: await dedupFor(visitorId, flagKey, flag.currentVersion, assigned, event),
|
|
155
|
+
ts: Math.floor(Date.now() / 1000),
|
|
156
|
+
properties,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Config cache (SWR via CacheAdapter)
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
function configCacheKey(siteName: string): string {
|
|
168
|
+
return `ab:config:${siteName}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function getConfig(
|
|
172
|
+
cache: CacheAdapter | undefined,
|
|
173
|
+
apiKey: string,
|
|
174
|
+
siteName: string,
|
|
175
|
+
baseUrl: string,
|
|
176
|
+
): Promise<AbConfigBlob> {
|
|
177
|
+
if (cache) {
|
|
178
|
+
const raw = await cache.get(configCacheKey(siteName));
|
|
179
|
+
if (raw) return JSON.parse(raw) as AbConfigBlob;
|
|
180
|
+
}
|
|
181
|
+
return fetchAndStoreConfig(cache, apiKey, siteName, baseUrl);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function fetchAndStoreConfig(
|
|
185
|
+
cache: CacheAdapter | undefined,
|
|
186
|
+
apiKey: string,
|
|
187
|
+
siteName: string,
|
|
188
|
+
baseUrl: string,
|
|
189
|
+
): Promise<AbConfigBlob> {
|
|
190
|
+
const res = await fetch(`${baseUrl}/_ab/config?site=${encodeURIComponent(siteName)}`, {
|
|
191
|
+
headers: { 'x-api-key': apiKey },
|
|
192
|
+
});
|
|
193
|
+
if (!res.ok) return { schemaVersion: 1, flags: {} };
|
|
194
|
+
const config = (await res.json()) as AbConfigBlob;
|
|
195
|
+
if (cache) {
|
|
196
|
+
await cache.set(configCacheKey(siteName), JSON.stringify(config), { ttlSeconds: CONFIG_TTL_SECONDS });
|
|
197
|
+
}
|
|
198
|
+
return config;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Event emission
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
interface EventBody {
|
|
206
|
+
site: string;
|
|
207
|
+
kind: 'exposure' | 'conversion' | 'handoff';
|
|
208
|
+
flag: string;
|
|
209
|
+
version: number;
|
|
210
|
+
variant: string;
|
|
211
|
+
visitorId: string;
|
|
212
|
+
dedup: string;
|
|
213
|
+
ts: number;
|
|
214
|
+
event?: string;
|
|
215
|
+
properties?: Record<string, unknown>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function emit(
|
|
219
|
+
apiKey: string,
|
|
220
|
+
baseUrl: string,
|
|
221
|
+
ctx: { waitUntil(p: Promise<unknown>): void } | undefined,
|
|
222
|
+
body: EventBody,
|
|
223
|
+
): void {
|
|
224
|
+
const task = fetch(`${baseUrl}/_ab/event`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: { 'content-type': 'application/json', 'x-api-key': apiKey },
|
|
227
|
+
body: JSON.stringify(body),
|
|
228
|
+
})
|
|
229
|
+
.then(() => undefined)
|
|
230
|
+
.catch(() => undefined);
|
|
231
|
+
if (ctx) ctx.waitUntil(task);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Visitor ID
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
function readOrMintVid(request: Request): { visitorId: string; mintedNew: boolean } {
|
|
239
|
+
const cookie = request.headers.get('cookie') ?? '';
|
|
240
|
+
const match = cookie.match(/(?:^|; )perspect_vid=([^;]+)/);
|
|
241
|
+
if (match) return { visitorId: decodeURIComponent(match[1]), mintedNew: false };
|
|
242
|
+
return { visitorId: crypto.randomUUID().replace(/-/g, ''), mintedNew: true };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Dedup hash
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
async function dedupFor(
|
|
250
|
+
visitorId: string,
|
|
251
|
+
flagKey: string,
|
|
252
|
+
version: number,
|
|
253
|
+
variant: string,
|
|
254
|
+
event: string,
|
|
255
|
+
): Promise<string> {
|
|
256
|
+
const input = `${visitorId}|${flagKey}|${version}|${variant}|${event}`;
|
|
257
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
|
258
|
+
const bytes = new Uint8Array(digest);
|
|
259
|
+
let hex = '';
|
|
260
|
+
for (let i = 0; i < 16; i++) hex += bytes[i].toString(16).padStart(2, '0');
|
|
261
|
+
return hex;
|
|
262
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic bucketing for A/B assignments.
|
|
3
|
+
*
|
|
4
|
+
* Both the site SDK (local assignment) and the platform event endpoint
|
|
5
|
+
* (server-side validation) compute the same hash → variant mapping.
|
|
6
|
+
* Any change here must ship to both simultaneously, or assignments and
|
|
7
|
+
* validation will diverge.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const BUCKET_SPACE = 10000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* FNV-1a 32-bit over UTF-8 bytes, reduced mod 10000.
|
|
14
|
+
* Pure function, no crypto — determinism and portability beat pre-image
|
|
15
|
+
* resistance here. The attack surface on the mapping is the same for
|
|
16
|
+
* both sides; what matters is that site and platform agree bit-for-bit.
|
|
17
|
+
*/
|
|
18
|
+
export function bucketFor(
|
|
19
|
+
visitorId: string,
|
|
20
|
+
flagKey: string,
|
|
21
|
+
version: number,
|
|
22
|
+
): number {
|
|
23
|
+
const input = `${visitorId}|${flagKey}|${version}`;
|
|
24
|
+
const bytes = new TextEncoder().encode(input);
|
|
25
|
+
let hash = 0x811c9dc5;
|
|
26
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
27
|
+
hash ^= bytes[i];
|
|
28
|
+
hash = Math.imul(hash, 0x01000193);
|
|
29
|
+
}
|
|
30
|
+
return (hash >>> 0) % BUCKET_SPACE;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a bucket to a variant using cumulative weightBp ranges.
|
|
35
|
+
* Variants are consumed in the order given — callers must sort variants
|
|
36
|
+
* consistently on both sides (we sort by key).
|
|
37
|
+
*
|
|
38
|
+
* Returns null iff bucket >= trafficAllocationBp (visitor not enrolled).
|
|
39
|
+
*/
|
|
40
|
+
export function variantForBucket(
|
|
41
|
+
bucket: number,
|
|
42
|
+
variants: { key: string; weightBp: number }[],
|
|
43
|
+
trafficAllocationBp: number,
|
|
44
|
+
): string | null {
|
|
45
|
+
if (bucket >= trafficAllocationBp) return null;
|
|
46
|
+
const sorted = [...variants].sort((a, b) => (a.key < b.key ? -1 : 1));
|
|
47
|
+
const totalWeight = sorted.reduce((acc, v) => acc + v.weightBp, 0);
|
|
48
|
+
if (totalWeight <= 0) return null;
|
|
49
|
+
const scaled = Math.floor((bucket * totalWeight) / trafficAllocationBp);
|
|
50
|
+
let cursor = 0;
|
|
51
|
+
for (const v of sorted) {
|
|
52
|
+
cursor += v.weightBp;
|
|
53
|
+
if (scaled < cursor) return v.key;
|
|
54
|
+
}
|
|
55
|
+
return sorted[sorted.length - 1].key;
|
|
56
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -58,12 +58,25 @@ export { BaseClient } from './client/base-client';
|
|
|
58
58
|
|
|
59
59
|
// Utilities
|
|
60
60
|
export { HttpClient, createApiError } from './utils/http-client';
|
|
61
|
+
export { PerspectApiError } from './types';
|
|
61
62
|
|
|
62
63
|
// Cache utilities
|
|
63
64
|
export { CacheManager } from './cache/cache-manager';
|
|
64
65
|
export { CloudflareKVCacheAdapter } from './cache/cloudflare-kv-adapter';
|
|
65
|
-
|
|
66
|
+
export { InMemoryCacheAdapter } from './cache/in-memory-adapter';
|
|
66
67
|
export { NoopCacheAdapter } from './cache/noop-adapter';
|
|
68
|
+
export type { CacheAdapter, CacheConfig } from './cache/types';
|
|
69
|
+
|
|
70
|
+
// A/B testing
|
|
71
|
+
export { createPerspectAb } from './ab/ab-client';
|
|
72
|
+
export { bucketFor, variantForBucket } from './ab/bucketing';
|
|
73
|
+
export type {
|
|
74
|
+
CreatePerspectAbOptions,
|
|
75
|
+
PerspectAb,
|
|
76
|
+
AbClient,
|
|
77
|
+
AbVariantResult,
|
|
78
|
+
AbForRequestResult,
|
|
79
|
+
} from './ab/ab-client';
|
|
67
80
|
|
|
68
81
|
// Image transformation utilities
|
|
69
82
|
export {
|
package/src/types/index.ts
CHANGED
|
@@ -1138,6 +1138,20 @@ export interface ApiError {
|
|
|
1138
1138
|
details?: any;
|
|
1139
1139
|
}
|
|
1140
1140
|
|
|
1141
|
+
export class PerspectApiError extends Error implements ApiError {
|
|
1142
|
+
code?: string;
|
|
1143
|
+
status?: number;
|
|
1144
|
+
details?: any;
|
|
1145
|
+
|
|
1146
|
+
constructor({ message, code, status, details }: ApiError) {
|
|
1147
|
+
super(message);
|
|
1148
|
+
this.name = 'PerspectApiError';
|
|
1149
|
+
this.code = code;
|
|
1150
|
+
this.status = status;
|
|
1151
|
+
this.details = details;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1141
1155
|
// SDK Configuration
|
|
1142
1156
|
export interface PerspectApiConfig {
|
|
1143
1157
|
baseUrl: string;
|