@variantlab/next 0.1.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/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/app-router.cjs +325 -0
- package/dist/app-router.cjs.map +1 -0
- package/dist/app-router.d.cts +41 -0
- package/dist/app-router.d.ts +41 -0
- package/dist/app-router.js +309 -0
- package/dist/app-router.js.map +1 -0
- package/dist/client/hooks.cjs +191 -0
- package/dist/client/hooks.cjs.map +1 -0
- package/dist/client/hooks.d.cts +62 -0
- package/dist/client/hooks.d.ts +62 -0
- package/dist/client/hooks.js +178 -0
- package/dist/client/hooks.js.map +1 -0
- package/dist/index.cjs +319 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +318 -0
- package/dist/index.d.ts +318 -0
- package/dist/index.js +304 -0
- package/dist/index.js.map +1 -0
- package/dist/pages-router.cjs +325 -0
- package/dist/pages-router.cjs.map +1 -0
- package/dist/pages-router.d.cts +26 -0
- package/dist/pages-router.d.ts +26 -0
- package/dist/pages-router.js +309 -0
- package/dist/pages-router.js.map +1 -0
- package/package.json +105 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { ExperimentsConfig, VariantContext } from '@variantlab/core';
|
|
2
|
+
export { AssignmentStrategy, Experiment, ExperimentsConfig, Variant, VariantContext } from '@variantlab/core';
|
|
3
|
+
import { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared types for `@variantlab/next`.
|
|
7
|
+
*
|
|
8
|
+
* Kept in a single tiny file so every entrypoint (server barrel,
|
|
9
|
+
* app-router subpath, pages-router subpath, client provider) can
|
|
10
|
+
* import without pulling in code.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* On-wire cookie payload shape. Short keys to minimize cookie bytes.
|
|
15
|
+
*
|
|
16
|
+
* v — schema version (currently 1)
|
|
17
|
+
* u — userId (required; generated on first visit by middleware)
|
|
18
|
+
* a — assignments: { [experimentId]: variantId } (may be empty)
|
|
19
|
+
*/
|
|
20
|
+
interface StickyCookiePayload {
|
|
21
|
+
readonly v: 1;
|
|
22
|
+
readonly u: string;
|
|
23
|
+
readonly a: Readonly<Record<string, string>>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Anything from which we can read an HTTP Cookie header. Supports:
|
|
27
|
+
*
|
|
28
|
+
* - Fetch-style `Request` (App Router Route Handlers, middleware)
|
|
29
|
+
* - `NextApiRequest` / Pages Router `req` (has `.cookies` and `.headers`)
|
|
30
|
+
* - Next 14+ `ReadonlyRequestCookies` from `cookies()` in `next/headers`
|
|
31
|
+
* - A plain cookie header string
|
|
32
|
+
*/
|
|
33
|
+
type CookieSource = Request | RequestCookieJar | PagesRouterRequestLike | string | null | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Shape we rely on from Next's `cookies()` return value. Kept minimal
|
|
36
|
+
* so we don't depend on `next/headers` types at compile time — the
|
|
37
|
+
* server barrel must be framework-agnostic enough to tree-shake.
|
|
38
|
+
*/
|
|
39
|
+
interface RequestCookieJar {
|
|
40
|
+
get(name: string): {
|
|
41
|
+
readonly value: string;
|
|
42
|
+
} | undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Minimal Pages Router / `NextApiRequest` shape. Avoids importing
|
|
46
|
+
* `next` types in the base entrypoint.
|
|
47
|
+
*/
|
|
48
|
+
interface PagesRouterRequestLike {
|
|
49
|
+
readonly cookies?: Readonly<Record<string, string | undefined>>;
|
|
50
|
+
readonly headers?: Readonly<Record<string, string | string[] | undefined>>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Options accepted by `createVariantLabServer` and the SSR helpers.
|
|
54
|
+
*/
|
|
55
|
+
interface VariantLabServerOptions {
|
|
56
|
+
/** Cookie name. Defaults to `__variantlab_sticky`. */
|
|
57
|
+
readonly cookieName?: string;
|
|
58
|
+
/** Max age in seconds. Defaults to 365 days. */
|
|
59
|
+
readonly maxAge?: number;
|
|
60
|
+
/** Cookie path. Defaults to `/`. */
|
|
61
|
+
readonly path?: string;
|
|
62
|
+
/** `SameSite` attribute. Defaults to `"lax"`. */
|
|
63
|
+
readonly sameSite?: "strict" | "lax" | "none";
|
|
64
|
+
/** Override for the `Secure` flag. When `undefined`, derived from the request URL. */
|
|
65
|
+
readonly secure?: boolean;
|
|
66
|
+
/** `HttpOnly` flag. Defaults to `true`. */
|
|
67
|
+
readonly httpOnly?: boolean;
|
|
68
|
+
/** `Domain` attribute. Defaults to undefined (host-only cookie). */
|
|
69
|
+
readonly domain?: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Props accepted by the Next `VariantLabProvider` Client Component.
|
|
73
|
+
*/
|
|
74
|
+
interface VariantLabProviderProps {
|
|
75
|
+
/** Validated `ExperimentsConfig` or a raw JSON module import. */
|
|
76
|
+
readonly config: unknown | ExperimentsConfig;
|
|
77
|
+
/** Runtime context (userId, locale, platform, …) applied before first render. */
|
|
78
|
+
readonly initialContext?: VariantContext;
|
|
79
|
+
/**
|
|
80
|
+
* Assignments computed on the server. Seeded into the engine cache so
|
|
81
|
+
* the first `getVariant` call on the client returns the same variant
|
|
82
|
+
* that was server-rendered, without re-evaluating targeting.
|
|
83
|
+
*/
|
|
84
|
+
readonly initialVariants?: Readonly<Record<string, string>>;
|
|
85
|
+
readonly children?: ReactNode;
|
|
86
|
+
}
|
|
87
|
+
/** Default cookie name. Shared between server helpers and middleware. */
|
|
88
|
+
declare const DEFAULT_COOKIE_NAME = "__variantlab_sticky";
|
|
89
|
+
/** Default max age (365 days, in seconds). */
|
|
90
|
+
declare const DEFAULT_MAX_AGE: number;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Hand-rolled cookie codec + parser for `@variantlab/next`.
|
|
94
|
+
*
|
|
95
|
+
* Goals:
|
|
96
|
+
* 1. Zero runtime dependencies. No `cookie` package, no `js-base64`.
|
|
97
|
+
* 2. Edge-runtime safe. Uses only `TextEncoder`/`TextDecoder`,
|
|
98
|
+
* `globalThis.crypto`, standard `atob`/`btoa`, and `Object.create(null)`
|
|
99
|
+
* — all available on Vercel Edge, Cloudflare Workers, Deno, Bun, Node 18+.
|
|
100
|
+
* 3. Prototype-pollution hardened. Mirrors the guards in
|
|
101
|
+
* `packages/core/src/config/validator.ts`: cookie parser rejects
|
|
102
|
+
* `__proto__`, `constructor`, `prototype` as cookie names, and the
|
|
103
|
+
* JSON payload is parsed through `Object.create(null)` sanitization.
|
|
104
|
+
* 4. Size-capped. A malicious client sending a 10 MB Cookie header gets
|
|
105
|
+
* rejected before allocation by `MAX_COOKIE_HEADER_BYTES`.
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Encode a `StickyCookiePayload` to its on-wire value. Does NOT include
|
|
110
|
+
* the cookie name or attributes — see {@link serializeCookie}.
|
|
111
|
+
*/
|
|
112
|
+
declare function encodePayload(payload: StickyCookiePayload): string;
|
|
113
|
+
/**
|
|
114
|
+
* Decode a base64url-encoded cookie value back to a `StickyCookiePayload`.
|
|
115
|
+
* Returns `null` for anything that fails validation. Never throws.
|
|
116
|
+
*/
|
|
117
|
+
declare function decodePayload(raw: string | undefined | null): StickyCookiePayload | null;
|
|
118
|
+
/**
|
|
119
|
+
* Parse a raw `Cookie:` header into a `null`-prototype map of
|
|
120
|
+
* `name → value`. Tolerates leading whitespace, empty segments,
|
|
121
|
+
* missing `=`, and rejects reserved names.
|
|
122
|
+
*/
|
|
123
|
+
declare function parseCookieHeader(header: string | undefined | null): Record<string, string>;
|
|
124
|
+
/**
|
|
125
|
+
* Build a `Set-Cookie` header value: `name=value; attr=...; ...`.
|
|
126
|
+
* Cookie value is URL-encoded so any non-ASCII bytes round-trip through
|
|
127
|
+
* a standards-compliant parser.
|
|
128
|
+
*/
|
|
129
|
+
declare function serializeCookie(name: string, value: string, options?: VariantLabServerOptions & {
|
|
130
|
+
readonly secure?: boolean;
|
|
131
|
+
}): string;
|
|
132
|
+
/**
|
|
133
|
+
* Read a named cookie from any supported source: `Request`, Next's
|
|
134
|
+
* `ReadonlyRequestCookies`, a Pages Router `NextApiRequest`, or a
|
|
135
|
+
* raw header string. Returns `undefined` when missing.
|
|
136
|
+
*/
|
|
137
|
+
declare function readCookieFromSource(source: CookieSource, name?: string): string | undefined;
|
|
138
|
+
/**
|
|
139
|
+
* Full helper: read the sticky payload from a source, returning
|
|
140
|
+
* `null` if the cookie is missing, malformed, or fails validation.
|
|
141
|
+
*/
|
|
142
|
+
declare function readPayloadFromSource(source: CookieSource, name?: string): StickyCookiePayload | null;
|
|
143
|
+
/**
|
|
144
|
+
* Generate a v4 UUID via `crypto.randomUUID`, falling back to a
|
|
145
|
+
* hand-formatted v4 built from `crypto.getRandomValues`. Both APIs
|
|
146
|
+
* are Web Crypto and are available on every Phase 1 target runtime.
|
|
147
|
+
*/
|
|
148
|
+
declare function generateUserId(): string;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `createVariantLabServer(config, options?)` — factory that validates the
|
|
152
|
+
* config once and returns request-scoped helpers. Each call to
|
|
153
|
+
* `getVariant` / `getVariantValue` constructs a short-lived engine
|
|
154
|
+
* seeded from the request's cookie so concurrent HTTP requests never
|
|
155
|
+
* share mutable state.
|
|
156
|
+
*
|
|
157
|
+
* Validation is expensive; engine construction is cheap (a few Maps,
|
|
158
|
+
* no I/O, no allocation beyond what's already in the frozen config).
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
interface VariantLabServer {
|
|
162
|
+
/** The frozen, validated config this server was built with. */
|
|
163
|
+
readonly config: ExperimentsConfig;
|
|
164
|
+
/**
|
|
165
|
+
* Resolve a variant id using the given cookie source + optional
|
|
166
|
+
* context extras. Always returns the default if anything fails.
|
|
167
|
+
*/
|
|
168
|
+
getVariant(experimentId: string, source: CookieSource, context?: VariantContext): string;
|
|
169
|
+
/**
|
|
170
|
+
* Resolve a variant's `value` payload. Equivalent to calling
|
|
171
|
+
* `getVariant` + looking up the variant by id.
|
|
172
|
+
*/
|
|
173
|
+
getVariantValue<T = unknown>(experimentId: string, source: CookieSource, context?: VariantContext): T;
|
|
174
|
+
/**
|
|
175
|
+
* Decode the sticky cookie. Returns `null` when missing or invalid.
|
|
176
|
+
* Layouts pass this directly to `<VariantLabProvider initialVariants={...}>`.
|
|
177
|
+
*/
|
|
178
|
+
readPayload(source: CookieSource): StickyCookiePayload | null;
|
|
179
|
+
/**
|
|
180
|
+
* Build a `Set-Cookie` header value for a freshly-computed payload.
|
|
181
|
+
* Caller is responsible for attaching it to the outgoing response.
|
|
182
|
+
*/
|
|
183
|
+
writePayload(payload: StickyCookiePayload, secure?: boolean): string;
|
|
184
|
+
/**
|
|
185
|
+
* Build the `initialContext` + `initialVariants` props that the
|
|
186
|
+
* Next `<VariantLabProvider>` needs. Convenience for layouts:
|
|
187
|
+
*
|
|
188
|
+
* const props = server.toProviderProps(cookies(), { locale: "en" });
|
|
189
|
+
* return <VariantLabProvider {...props}>{children}</VariantLabProvider>;
|
|
190
|
+
*/
|
|
191
|
+
toProviderProps(source: CookieSource, contextExtras?: VariantContext): Omit<VariantLabProviderProps, "children" | "config">;
|
|
192
|
+
}
|
|
193
|
+
interface CreateVariantLabServerOptions extends VariantLabServerOptions {
|
|
194
|
+
/**
|
|
195
|
+
* Called when cookie reads or engine construction fails catastrophically.
|
|
196
|
+
* Defaults to a no-op (fail-open).
|
|
197
|
+
*/
|
|
198
|
+
readonly onError?: (error: Error) => void;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Validate the supplied config once and return a factory whose methods
|
|
202
|
+
* build a new `VariantEngine` per call. This is the entry point that
|
|
203
|
+
* layouts, server components, route handlers, and `getServerSideProps`
|
|
204
|
+
* should use when they want to resolve variants at SSR time.
|
|
205
|
+
*/
|
|
206
|
+
declare function createVariantLabServer(rawConfig: unknown, options?: CreateVariantLabServerOptions): VariantLabServer;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* `getVariantSSR` / `getVariantValueSSR` — per-request SSR helpers that
|
|
210
|
+
* don't require the caller to hold a `VariantLabServer` instance.
|
|
211
|
+
*
|
|
212
|
+
* Internally they use a `WeakMap` keyed on the raw config object identity
|
|
213
|
+
* so repeat calls with the same imported JSON module don't re-validate
|
|
214
|
+
* the config. The WeakMap doesn't retain anything — if the caller drops
|
|
215
|
+
* the config reference, the cached server is GC'd.
|
|
216
|
+
*
|
|
217
|
+
* Signature matches `API.md` lines 569–582 (synchronous). See the
|
|
218
|
+
* package README for notes on spec drift vs. the Session 7 prompt.
|
|
219
|
+
*/
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolve a variant for the current request. Reads the sticky cookie
|
|
223
|
+
* from the supplied source (`Request`, `ReadonlyRequestCookies`,
|
|
224
|
+
* `NextApiRequest`, or a raw cookie header string).
|
|
225
|
+
*
|
|
226
|
+
* Synchronous. In Next 15, `cookies()` is async — await it and pass the
|
|
227
|
+
* resolved store into this function.
|
|
228
|
+
*/
|
|
229
|
+
declare function getVariantSSR(experimentId: string, source: CookieSource, config: unknown | ExperimentsConfig, options?: CreateVariantLabServerOptions & {
|
|
230
|
+
readonly context?: VariantContext;
|
|
231
|
+
}): string;
|
|
232
|
+
/** Variant-value equivalent of {@link getVariantSSR}. */
|
|
233
|
+
declare function getVariantValueSSR<T = unknown>(experimentId: string, source: CookieSource, config: unknown | ExperimentsConfig, options?: CreateVariantLabServerOptions & {
|
|
234
|
+
readonly context?: VariantContext;
|
|
235
|
+
}): T;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* `variantLabMiddleware(config, options?)` — Next.js middleware factory.
|
|
239
|
+
*
|
|
240
|
+
* Responsibilities (Phase 1):
|
|
241
|
+
* 1. Read the sticky cookie from the incoming request.
|
|
242
|
+
* 2. If missing or malformed, mint a fresh userId and write an
|
|
243
|
+
* empty payload `{ v: 1, u: userId, a: {} }` on the outgoing
|
|
244
|
+
* response. Assignments are NOT computed at the edge.
|
|
245
|
+
* 3. Fail-open: any error → pass through unmodified.
|
|
246
|
+
*
|
|
247
|
+
* The factory takes the raw config only so it can validate it once at
|
|
248
|
+
* import time — middleware is instantiated per process, not per request.
|
|
249
|
+
* It does not resolve any experiments during middleware execution.
|
|
250
|
+
*
|
|
251
|
+
* Designed to run on the Vercel Edge runtime (`export const runtime = "edge"`).
|
|
252
|
+
* No Node-only APIs. No `process.env` access.
|
|
253
|
+
*/
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The minimal shape we need from `NextRequest`. Avoids a hard
|
|
257
|
+
* dependency on `next/server` at type-check time.
|
|
258
|
+
*/
|
|
259
|
+
interface NextRequestLike {
|
|
260
|
+
readonly headers: {
|
|
261
|
+
get(name: string): string | null;
|
|
262
|
+
};
|
|
263
|
+
readonly nextUrl: {
|
|
264
|
+
readonly protocol: string;
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The minimal shape we produce / consume for `NextResponse`. Matches
|
|
269
|
+
* `NextResponse.next()` / `NextResponse.redirect()` return values.
|
|
270
|
+
*/
|
|
271
|
+
interface NextResponseLike {
|
|
272
|
+
readonly headers: {
|
|
273
|
+
append(name: string, value: string): void;
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
interface VariantLabMiddlewareOptions extends VariantLabServerOptions {
|
|
277
|
+
/**
|
|
278
|
+
* Called on any caught error. Defaults to a no-op so the middleware
|
|
279
|
+
* remains fail-open. Provide a logger here if you want visibility.
|
|
280
|
+
*/
|
|
281
|
+
readonly onError?: (error: Error) => void;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Build a middleware function. Accepts the raw response factory from
|
|
285
|
+
* the caller so we don't need to import `NextResponse` directly (that
|
|
286
|
+
* would drag `next/server` into every consumer's bundle).
|
|
287
|
+
*
|
|
288
|
+
* Typical usage in `middleware.ts`:
|
|
289
|
+
*
|
|
290
|
+
* import { NextResponse } from "next/server";
|
|
291
|
+
* import experiments from "./experiments.json";
|
|
292
|
+
* import { variantLabMiddleware } from "@variantlab/next";
|
|
293
|
+
*
|
|
294
|
+
* const middleware = variantLabMiddleware(experiments);
|
|
295
|
+
*
|
|
296
|
+
* export default function (req) {
|
|
297
|
+
* return middleware(req, NextResponse.next());
|
|
298
|
+
* }
|
|
299
|
+
*
|
|
300
|
+
* The factory also exports `middleware.handle(req, nextResponseFactory)`
|
|
301
|
+
* for the more idiomatic style where NextResponse is only imported once.
|
|
302
|
+
*/
|
|
303
|
+
declare function variantLabMiddleware(rawConfig: unknown, options?: VariantLabMiddlewareOptions): <TResponse extends NextResponseLike>(req: NextRequestLike, response: TResponse) => TResponse;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* `@variantlab/next` — server barrel.
|
|
307
|
+
*
|
|
308
|
+
* This file is the default entrypoint when consumers write
|
|
309
|
+
* `import { ... } from "@variantlab/next"`. It exposes only
|
|
310
|
+
* server-safe helpers (no `"use client"`, no React). Use
|
|
311
|
+
* `@variantlab/next/client` for the provider and hooks.
|
|
312
|
+
*
|
|
313
|
+
* Edge-runtime compatible: no Node-only APIs, no `process.env`, and
|
|
314
|
+
* no runtime dependencies beyond `@variantlab/core`.
|
|
315
|
+
*/
|
|
316
|
+
declare const VERSION = "0.0.0";
|
|
317
|
+
|
|
318
|
+
export { type CookieSource, type CreateVariantLabServerOptions, DEFAULT_COOKIE_NAME, DEFAULT_MAX_AGE, type PagesRouterRequestLike, type RequestCookieJar, type StickyCookiePayload, VERSION, type VariantLabMiddlewareOptions, type VariantLabProviderProps, type VariantLabServer, type VariantLabServerOptions, createVariantLabServer, decodePayload, encodePayload, generateUserId, getVariantSSR, getVariantValueSSR, parseCookieHeader, readCookieFromSource, readPayloadFromSource, serializeCookie, variantLabMiddleware };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { validateConfig, createEngine } from '@variantlab/core';
|
|
2
|
+
|
|
3
|
+
// src/types.ts
|
|
4
|
+
var DEFAULT_COOKIE_NAME = "__variantlab_sticky";
|
|
5
|
+
var DEFAULT_MAX_AGE = 60 * 60 * 24 * 365;
|
|
6
|
+
|
|
7
|
+
// src/server/cookie.ts
|
|
8
|
+
var MAX_COOKIE_HEADER_BYTES = 8192;
|
|
9
|
+
var MAX_PAYLOAD_BYTES = 4096;
|
|
10
|
+
var RESERVED_NAMES = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
11
|
+
var textEncoder = new TextEncoder();
|
|
12
|
+
var textDecoder = new TextDecoder();
|
|
13
|
+
function base64urlEncode(input) {
|
|
14
|
+
const bytes = textEncoder.encode(input);
|
|
15
|
+
let binary = "";
|
|
16
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
17
|
+
binary += String.fromCharCode(bytes[i]);
|
|
18
|
+
}
|
|
19
|
+
const b64 = btoa(binary);
|
|
20
|
+
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
21
|
+
}
|
|
22
|
+
function base64urlDecode(input) {
|
|
23
|
+
if (!/^[A-Za-z0-9\-_]*$/.test(input)) return null;
|
|
24
|
+
const pad = input.length % 4;
|
|
25
|
+
const padded = input.replace(/-/g, "+").replace(/_/g, "/") + "====".slice(pad === 0 ? 4 : pad);
|
|
26
|
+
try {
|
|
27
|
+
const binary = atob(padded);
|
|
28
|
+
const bytes = new Uint8Array(binary.length);
|
|
29
|
+
for (let i = 0; i < binary.length; i++) {
|
|
30
|
+
bytes[i] = binary.charCodeAt(i);
|
|
31
|
+
}
|
|
32
|
+
return textDecoder.decode(bytes);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function encodePayload(payload) {
|
|
38
|
+
const json = JSON.stringify({ v: payload.v, u: payload.u, a: payload.a });
|
|
39
|
+
return base64urlEncode(json);
|
|
40
|
+
}
|
|
41
|
+
function decodePayload(raw) {
|
|
42
|
+
if (typeof raw !== "string" || raw.length === 0) return null;
|
|
43
|
+
if (raw.length > MAX_PAYLOAD_BYTES) return null;
|
|
44
|
+
const json = base64urlDecode(raw);
|
|
45
|
+
if (json === null) return null;
|
|
46
|
+
if (json.length > MAX_PAYLOAD_BYTES) return null;
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(json);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
54
|
+
const obj = parsed;
|
|
55
|
+
if (obj["v"] !== 1) return null;
|
|
56
|
+
if (typeof obj["u"] !== "string" || obj["u"].length === 0 || obj["u"].length > 256) return null;
|
|
57
|
+
const rawA = obj["a"];
|
|
58
|
+
if (rawA === null || typeof rawA !== "object" || Array.isArray(rawA)) return null;
|
|
59
|
+
const src = rawA;
|
|
60
|
+
const a = /* @__PURE__ */ Object.create(null);
|
|
61
|
+
for (const key of Object.keys(src)) {
|
|
62
|
+
if (RESERVED_NAMES.has(key)) continue;
|
|
63
|
+
if (key.length === 0 || key.length > 128) continue;
|
|
64
|
+
const val = src[key];
|
|
65
|
+
if (typeof val !== "string" || val.length === 0 || val.length > 128) continue;
|
|
66
|
+
a[key] = val;
|
|
67
|
+
}
|
|
68
|
+
return { v: 1, u: obj["u"], a };
|
|
69
|
+
}
|
|
70
|
+
function parseCookieHeader(header) {
|
|
71
|
+
const out = /* @__PURE__ */ Object.create(null);
|
|
72
|
+
if (typeof header !== "string" || header.length === 0) return out;
|
|
73
|
+
if (header.length > MAX_COOKIE_HEADER_BYTES) return out;
|
|
74
|
+
let i = 0;
|
|
75
|
+
const n = header.length;
|
|
76
|
+
while (i < n) {
|
|
77
|
+
while (i < n && (header.charCodeAt(i) === 32 || header.charCodeAt(i) === 9)) i++;
|
|
78
|
+
const start = i;
|
|
79
|
+
while (i < n && header.charCodeAt(i) !== 59) i++;
|
|
80
|
+
const segment = header.slice(start, i);
|
|
81
|
+
if (i < n) i++;
|
|
82
|
+
if (segment.length === 0) continue;
|
|
83
|
+
const eq = segment.indexOf("=");
|
|
84
|
+
if (eq <= 0) continue;
|
|
85
|
+
const name = segment.slice(0, eq).trim();
|
|
86
|
+
if (name.length === 0) continue;
|
|
87
|
+
if (RESERVED_NAMES.has(name)) continue;
|
|
88
|
+
let value = segment.slice(eq + 1).trim();
|
|
89
|
+
if (value.length >= 2 && value.charCodeAt(0) === 34 && value.charCodeAt(value.length - 1) === 34) {
|
|
90
|
+
value = value.slice(1, -1);
|
|
91
|
+
}
|
|
92
|
+
if (out[name] === void 0) {
|
|
93
|
+
out[name] = safeDecodeURIComponent(value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
function safeDecodeURIComponent(s) {
|
|
99
|
+
try {
|
|
100
|
+
return decodeURIComponent(s);
|
|
101
|
+
} catch {
|
|
102
|
+
return s;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function serializeCookie(name, value, options = {}) {
|
|
106
|
+
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
107
|
+
const maxAge = options.maxAge ?? DEFAULT_MAX_AGE;
|
|
108
|
+
if (maxAge > 0) parts.push(`Max-Age=${Math.floor(maxAge)}`);
|
|
109
|
+
parts.push(`Path=${options.path ?? "/"}`);
|
|
110
|
+
const sameSite = options.sameSite ?? "lax";
|
|
111
|
+
parts.push(`SameSite=${capitalize(sameSite)}`);
|
|
112
|
+
if (options.httpOnly !== false) parts.push("HttpOnly");
|
|
113
|
+
if (options.secure === true) parts.push("Secure");
|
|
114
|
+
if (options.domain !== void 0) parts.push(`Domain=${options.domain}`);
|
|
115
|
+
return parts.join("; ");
|
|
116
|
+
}
|
|
117
|
+
function capitalize(s) {
|
|
118
|
+
if (s.length === 0) return s;
|
|
119
|
+
return s[0].toUpperCase() + s.slice(1);
|
|
120
|
+
}
|
|
121
|
+
function readCookieFromSource(source, name = DEFAULT_COOKIE_NAME) {
|
|
122
|
+
if (source === null || source === void 0) return void 0;
|
|
123
|
+
if (typeof source === "string") {
|
|
124
|
+
const parsed = parseCookieHeader(source);
|
|
125
|
+
return parsed[name];
|
|
126
|
+
}
|
|
127
|
+
if (typeof source.get === "function") {
|
|
128
|
+
const entry = source.get(name);
|
|
129
|
+
return entry === void 0 ? void 0 : entry.value;
|
|
130
|
+
}
|
|
131
|
+
if (typeof source.headers?.get === "function") {
|
|
132
|
+
const header = source.headers.get("cookie");
|
|
133
|
+
if (header === null) return void 0;
|
|
134
|
+
return parseCookieHeader(header)[name];
|
|
135
|
+
}
|
|
136
|
+
const pagesReq = source;
|
|
137
|
+
if (pagesReq.cookies !== void 0) {
|
|
138
|
+
const fromBag = pagesReq.cookies[name];
|
|
139
|
+
if (typeof fromBag === "string" && fromBag.length > 0) return fromBag;
|
|
140
|
+
}
|
|
141
|
+
if (pagesReq.headers !== void 0) {
|
|
142
|
+
const header = pagesReq.headers["cookie"];
|
|
143
|
+
if (typeof header === "string") return parseCookieHeader(header)[name];
|
|
144
|
+
if (Array.isArray(header) && typeof header[0] === "string") {
|
|
145
|
+
return parseCookieHeader(header[0])[name];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return void 0;
|
|
149
|
+
}
|
|
150
|
+
function readPayloadFromSource(source, name = DEFAULT_COOKIE_NAME) {
|
|
151
|
+
const raw = readCookieFromSource(source, name);
|
|
152
|
+
return decodePayload(raw);
|
|
153
|
+
}
|
|
154
|
+
function generateUserId() {
|
|
155
|
+
const g = globalThis;
|
|
156
|
+
if (g.crypto?.randomUUID !== void 0) return g.crypto.randomUUID();
|
|
157
|
+
if (g.crypto?.getRandomValues !== void 0) {
|
|
158
|
+
const bytes = new Uint8Array(16);
|
|
159
|
+
g.crypto.getRandomValues(bytes);
|
|
160
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
161
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
162
|
+
const hex = [];
|
|
163
|
+
for (let i = 0; i < 16; i++) {
|
|
164
|
+
hex.push(bytes[i].toString(16).padStart(2, "0"));
|
|
165
|
+
}
|
|
166
|
+
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
|
|
167
|
+
}
|
|
168
|
+
return `u-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
|
|
169
|
+
}
|
|
170
|
+
function createVariantLabServer(rawConfig, options = {}) {
|
|
171
|
+
const config = validateConfig(rawConfig);
|
|
172
|
+
const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
173
|
+
const onError = options.onError ?? (() => {
|
|
174
|
+
});
|
|
175
|
+
function readPayload(source) {
|
|
176
|
+
try {
|
|
177
|
+
return readPayloadFromSource(source, cookieName);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
onError(error);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function buildContext(payload, extras) {
|
|
184
|
+
const userId = payload?.u ?? extras?.userId;
|
|
185
|
+
if (userId === void 0) return { ...extras };
|
|
186
|
+
return { ...extras, userId };
|
|
187
|
+
}
|
|
188
|
+
function getVariant(experimentId, source, context) {
|
|
189
|
+
try {
|
|
190
|
+
const payload = readPayload(source);
|
|
191
|
+
const engine = createEngine(config, {
|
|
192
|
+
context: buildContext(payload, context),
|
|
193
|
+
...payload?.a !== void 0 ? { initialAssignments: payload.a } : {}
|
|
194
|
+
});
|
|
195
|
+
return engine.getVariant(experimentId);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
onError(error);
|
|
198
|
+
return experimentDefault(config, experimentId);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function getVariantValue(experimentId, source, context) {
|
|
202
|
+
try {
|
|
203
|
+
const payload = readPayload(source);
|
|
204
|
+
const engine = createEngine(config, {
|
|
205
|
+
context: buildContext(payload, context),
|
|
206
|
+
...payload?.a !== void 0 ? { initialAssignments: payload.a } : {}
|
|
207
|
+
});
|
|
208
|
+
return engine.getVariantValue(experimentId);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
onError(error);
|
|
211
|
+
return void 0;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function writePayload(payload, secure) {
|
|
215
|
+
const value = encodePayload(payload);
|
|
216
|
+
return serializeCookie(cookieName, value, {
|
|
217
|
+
...options,
|
|
218
|
+
...secure !== void 0 ? { secure } : {}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function toProviderProps(source, contextExtras) {
|
|
222
|
+
const payload = readPayload(source);
|
|
223
|
+
const initialContext = buildContext(payload, contextExtras);
|
|
224
|
+
const initialVariants = payload?.a ? { ...payload.a } : {};
|
|
225
|
+
return { initialContext, initialVariants };
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
config,
|
|
229
|
+
getVariant,
|
|
230
|
+
getVariantValue,
|
|
231
|
+
readPayload,
|
|
232
|
+
writePayload,
|
|
233
|
+
toProviderProps
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function experimentDefault(config, experimentId) {
|
|
237
|
+
const exp = config.experiments.find((e) => e.id === experimentId);
|
|
238
|
+
return exp?.default ?? "";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// src/server/get-variant-ssr.ts
|
|
242
|
+
var cache = /* @__PURE__ */ new WeakMap();
|
|
243
|
+
function resolveServer(rawConfig, options) {
|
|
244
|
+
if (rawConfig !== null && typeof rawConfig === "object") {
|
|
245
|
+
const hit = cache.get(rawConfig);
|
|
246
|
+
if (hit !== void 0) return hit;
|
|
247
|
+
}
|
|
248
|
+
const server = createVariantLabServer(rawConfig, options);
|
|
249
|
+
if (rawConfig !== null && typeof rawConfig === "object") {
|
|
250
|
+
cache.set(rawConfig, server);
|
|
251
|
+
}
|
|
252
|
+
return server;
|
|
253
|
+
}
|
|
254
|
+
function getVariantSSR(experimentId, source, config, options) {
|
|
255
|
+
const server = resolveServer(config, options);
|
|
256
|
+
return server.getVariant(experimentId, source, options?.context);
|
|
257
|
+
}
|
|
258
|
+
function getVariantValueSSR(experimentId, source, config, options) {
|
|
259
|
+
const server = resolveServer(config, options);
|
|
260
|
+
return server.getVariantValue(experimentId, source, options?.context);
|
|
261
|
+
}
|
|
262
|
+
function variantLabMiddleware(rawConfig, options = {}) {
|
|
263
|
+
let frozen = true;
|
|
264
|
+
try {
|
|
265
|
+
validateConfig(rawConfig);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
options.onError?.(error);
|
|
268
|
+
frozen = false;
|
|
269
|
+
}
|
|
270
|
+
const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
271
|
+
const onError = options.onError ?? (() => {
|
|
272
|
+
});
|
|
273
|
+
return function apply(req, response) {
|
|
274
|
+
if (!frozen) return response;
|
|
275
|
+
try {
|
|
276
|
+
const header = req.headers.get("cookie");
|
|
277
|
+
const cookies = parseCookieHeader(header ?? "");
|
|
278
|
+
const existing = decodePayload(cookies[cookieName]);
|
|
279
|
+
if (existing !== null) return response;
|
|
280
|
+
const payload = {
|
|
281
|
+
v: 1,
|
|
282
|
+
u: generateUserId(),
|
|
283
|
+
a: {}
|
|
284
|
+
};
|
|
285
|
+
const secure = req.nextUrl.protocol === "https:";
|
|
286
|
+
const setCookie = serializeCookie(cookieName, encodePayload(payload), {
|
|
287
|
+
...options,
|
|
288
|
+
secure
|
|
289
|
+
});
|
|
290
|
+
response.headers.append("set-cookie", setCookie);
|
|
291
|
+
return response;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
onError(error);
|
|
294
|
+
return response;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/index.ts
|
|
300
|
+
var VERSION = "0.0.0";
|
|
301
|
+
|
|
302
|
+
export { DEFAULT_COOKIE_NAME, DEFAULT_MAX_AGE, VERSION, createVariantLabServer, decodePayload, encodePayload, generateUserId, getVariantSSR, getVariantValueSSR, parseCookieHeader, readCookieFromSource, readPayloadFromSource, serializeCookie, variantLabMiddleware };
|
|
303
|
+
//# sourceMappingURL=index.js.map
|
|
304
|
+
//# sourceMappingURL=index.js.map
|