next-evolve 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +136 -2
- package/dist/_virtual/_rolldown/runtime.js +12 -0
- package/dist/aggregate/attribution.d.ts +40 -0
- package/dist/aggregate/attribution.js +115 -0
- package/dist/aggregate/run.d.ts +21 -0
- package/dist/aggregate/run.js +139 -0
- package/dist/cli/aggregate.d.ts +9 -0
- package/dist/cli/aggregate.js +104 -0
- package/dist/cli/args.d.ts +12 -0
- package/dist/cli/args.js +23 -0
- package/dist/cli/cloud.d.ts +41 -0
- package/dist/cli/cloud.js +74 -0
- package/dist/cli/connect.d.ts +8 -0
- package/dist/cli/connect.js +46 -0
- package/dist/cli/detect.d.ts +26 -0
- package/dist/cli/detect.js +97 -0
- package/dist/cli/doctor.d.ts +34 -0
- package/dist/cli/doctor.js +230 -0
- package/dist/cli/env-file.d.ts +4 -0
- package/dist/cli/env-file.js +15 -0
- package/dist/cli/files.d.ts +25 -0
- package/dist/cli/files.js +173 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +81 -0
- package/dist/cli/init.d.ts +26 -0
- package/dist/cli/init.js +240 -0
- package/dist/cli/load-app.d.ts +19 -0
- package/dist/cli/load-app.js +67 -0
- package/dist/cli/migrate.d.ts +7 -0
- package/dist/cli/migrate.js +30 -0
- package/dist/cli/pages.d.ts +17 -0
- package/dist/cli/pages.js +304 -0
- package/dist/cli/plan.d.ts +27 -0
- package/dist/cli/plan.js +129 -0
- package/dist/cli/pull.d.ts +13 -0
- package/dist/cli/pull.js +62 -0
- package/dist/cli/push.d.ts +14 -0
- package/dist/cli/push.js +33 -0
- package/dist/client/beacon.d.ts +11 -0
- package/dist/client/beacon.js +51 -0
- package/dist/client/evolve-script.d.ts +6 -0
- package/dist/client/evolve-script.js +60 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +3 -0
- package/dist/cloud/manifest.d.ts +14 -0
- package/dist/cloud/manifest.js +46 -0
- package/dist/cloud/push.d.ts +21 -0
- package/dist/cloud/push.js +56 -0
- package/dist/cloud/regions.d.ts +8 -0
- package/dist/cloud/regions.js +25 -0
- package/dist/config/config.d.ts +51 -0
- package/dist/config/config.js +47 -0
- package/dist/config/goals.d.ts +50 -0
- package/dist/config/goals.js +85 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.js +3 -0
- package/dist/core/assign.d.ts +35 -0
- package/dist/core/assign.js +104 -0
- package/dist/core/bots.d.ts +5 -0
- package/dist/core/bots.js +25 -0
- package/dist/core/constants.d.ts +3 -0
- package/dist/core/constants.js +5 -0
- package/dist/core/cookies.d.ts +12 -0
- package/dist/core/cookies.js +25 -0
- package/dist/core/keys.d.ts +14 -0
- package/dist/core/keys.js +38 -0
- package/dist/core/log.d.ts +2 -0
- package/dist/core/log.js +10 -0
- package/dist/db/client.d.ts +22 -0
- package/dist/db/client.js +50 -0
- package/dist/db/migrate.d.ts +18 -0
- package/dist/db/migrate.js +43 -0
- package/dist/db/writer.d.ts +53 -0
- package/dist/db/writer.js +182 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/instrumentation/index.d.ts +24 -0
- package/dist/instrumentation/index.js +54 -0
- package/dist/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/typescript.d.ts +11169 -0
- package/dist/proxy/index.d.ts +2 -0
- package/dist/proxy/index.js +1 -0
- package/dist/proxy/proxy.d.ts +24 -0
- package/dist/proxy/proxy.js +120 -0
- package/dist/react/context.d.ts +6 -0
- package/dist/react/context.js +8 -0
- package/dist/react/optimizable.d.ts +37 -0
- package/dist/react/optimizable.js +38 -0
- package/dist/react/region.d.ts +17 -0
- package/dist/react/region.js +127 -0
- package/dist/react/render-bundle.d.ts +15 -0
- package/dist/react/render-bundle.js +43 -0
- package/dist/react/with-evolve.d.ts +29 -0
- package/dist/react/with-evolve.js +57 -0
- package/dist/server/aggregate-route.d.ts +16 -0
- package/dist/server/aggregate-route.js +68 -0
- package/dist/server/collect.d.ts +62 -0
- package/dist/server/collect.js +157 -0
- package/dist/server/events.d.ts +18 -0
- package/dist/server/events.js +50 -0
- package/dist/server/evolve.d.ts +20 -0
- package/dist/server/evolve.js +57 -0
- package/dist/server/handler.d.ts +17 -0
- package/dist/server/handler.js +105 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/runtime.d.ts +26 -0
- package/dist/server/runtime.js +45 -0
- package/dist/server/sync-route.d.ts +28 -0
- package/dist/server/sync-route.js +100 -0
- package/dist/server/track-route.d.ts +25 -0
- package/dist/server/track-route.js +49 -0
- package/dist/store/disk.d.ts +6 -0
- package/dist/store/disk.js +23 -0
- package/dist/store/fetch.d.ts +26 -0
- package/dist/store/fetch.js +74 -0
- package/dist/store/global-config.d.ts +39 -0
- package/dist/store/global-config.js +122 -0
- package/dist/store/memory.d.ts +39 -0
- package/dist/store/memory.js +72 -0
- package/dist/store/runtime.d.ts +44 -0
- package/dist/store/runtime.js +101 -0
- package/migrations/0001_init.sql +56 -0
- package/package.json +73 -3
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { flush, push } from "./beacon.js";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
// src/client/evolve-script.tsx
|
|
6
|
+
/**
|
|
7
|
+
* Evolve beacon. Place once in the root layout. Not needed to show a variant.
|
|
8
|
+
* Queues a page view and one exposure per counted region on every route, and
|
|
9
|
+
* clicks/submits on `[data-evolve-goal]`; flushes every 2 s and on page hide.
|
|
10
|
+
*/
|
|
11
|
+
function EvolveScript() {
|
|
12
|
+
const path = usePathname();
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
document.documentElement.dataset.evolve = "ready";
|
|
15
|
+
const onGoal = (ev) => {
|
|
16
|
+
const el = ev.target?.closest?.("[data-evolve-goal]");
|
|
17
|
+
const n = el?.getAttribute("data-evolve-goal");
|
|
18
|
+
if (!n || ev.type === "submit" !== (el?.tagName === "FORM")) return;
|
|
19
|
+
push({
|
|
20
|
+
t: "ev",
|
|
21
|
+
n,
|
|
22
|
+
p: location.pathname
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
const onHide = () => document.visibilityState === "hidden" && flush();
|
|
26
|
+
document.addEventListener("click", onGoal, true);
|
|
27
|
+
document.addEventListener("submit", onGoal, true);
|
|
28
|
+
document.addEventListener("visibilitychange", onHide);
|
|
29
|
+
addEventListener("pagehide", flush);
|
|
30
|
+
return () => {
|
|
31
|
+
document.removeEventListener("click", onGoal, true);
|
|
32
|
+
document.removeEventListener("submit", onGoal, true);
|
|
33
|
+
document.removeEventListener("visibilitychange", onHide);
|
|
34
|
+
removeEventListener("pagehide", flush);
|
|
35
|
+
};
|
|
36
|
+
}, []);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const p = location.pathname;
|
|
39
|
+
push({
|
|
40
|
+
t: "pv",
|
|
41
|
+
p
|
|
42
|
+
});
|
|
43
|
+
const seen = /* @__PURE__ */ new Set();
|
|
44
|
+
for (const el of document.querySelectorAll("[data-evolve-e]:not([data-evolve-x])")) {
|
|
45
|
+
const e = el.getAttribute("data-evolve-e");
|
|
46
|
+
const v = el.getAttribute("data-evolve-v");
|
|
47
|
+
if (e && v && !seen.has(e)) {
|
|
48
|
+
seen.add(e);
|
|
49
|
+
push({
|
|
50
|
+
t: "exp",
|
|
51
|
+
e,
|
|
52
|
+
v,
|
|
53
|
+
p
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}, [path]);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
export { EvolveScript };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { EvolveConfig } from "../config/config.js";
|
|
2
|
+
import { Goals } from "../config/goals.js";
|
|
3
|
+
import { Manifest } from "@next-evolve/protocol";
|
|
4
|
+
/** This package's version (`0.0.0` if unknown). */
|
|
5
|
+
export declare function sdkVersion(): string;
|
|
6
|
+
/** The manifest this app sends to the cloud (spec §8.4). Configuration only. */
|
|
7
|
+
export declare function buildManifest(config: EvolveConfig, goals: Goals | undefined, opts?: {
|
|
8
|
+
appUrl?: string | null;
|
|
9
|
+
}): Manifest;
|
|
10
|
+
/**
|
|
11
|
+
* The app's public base URL: EVOLVE_APP_URL, else Vercel's production URL.
|
|
12
|
+
* Null when unknown (the dashboard asks for it).
|
|
13
|
+
*/
|
|
14
|
+
export declare function detectAppUrl(env?: Record<string, string | undefined>): string | null;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { resolveConfig } from "../config/config.js";
|
|
2
|
+
import { serializeGoals } from "../config/goals.js";
|
|
3
|
+
import { PROTOCOL_VERSION, SUPPORTED_PROTOCOLS, computeManifestHash } from "@next-evolve/protocol";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
// src/cloud/manifest.ts
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const versionOf = (load) => {
|
|
8
|
+
try {
|
|
9
|
+
return load().version;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
/** This package's version (`0.0.0` if unknown). */
|
|
15
|
+
function sdkVersion() {
|
|
16
|
+
return versionOf(() => require("next-evolve/package.json")) ?? "0.0.0";
|
|
17
|
+
}
|
|
18
|
+
/** The manifest this app sends to the cloud (spec §8.4). Configuration only. */
|
|
19
|
+
function buildManifest(config, goals, opts = {}) {
|
|
20
|
+
const routes = {};
|
|
21
|
+
for (const [path, r] of resolveConfig(config).routes) routes[path] = { mode: r.mode };
|
|
22
|
+
const serialized = goals ? serializeGoals(goals) : {};
|
|
23
|
+
return {
|
|
24
|
+
pv: PROTOCOL_VERSION,
|
|
25
|
+
sdkVersion: sdkVersion(),
|
|
26
|
+
protocol: { supported: [...SUPPORTED_PROTOCOLS] },
|
|
27
|
+
nextVersion: versionOf(() => require("next/package.json")),
|
|
28
|
+
routes,
|
|
29
|
+
goals: serialized,
|
|
30
|
+
hash: computeManifestHash({
|
|
31
|
+
routes,
|
|
32
|
+
goals: serialized
|
|
33
|
+
}),
|
|
34
|
+
appUrl: opts.appUrl ?? null
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The app's public base URL: EVOLVE_APP_URL, else Vercel's production URL.
|
|
39
|
+
* Null when unknown (the dashboard asks for it).
|
|
40
|
+
*/
|
|
41
|
+
function detectAppUrl(env = process.env) {
|
|
42
|
+
if (env.EVOLVE_APP_URL) return env.EVOLVE_APP_URL.replace(/\/$/, "");
|
|
43
|
+
if (env.VERCEL_ENV === "production" && env.VERCEL_PROJECT_PRODUCTION_URL) return `https://${env.VERCEL_PROJECT_PRODUCTION_URL}`;
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
export { buildManifest, detectAppUrl, sdkVersion };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type Env = Record<string, string | undefined>;
|
|
2
|
+
export type PushResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
status: number | "network" | "no_key";
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* POSTs configuration (manifest, regions) to the cloud with the site's secret
|
|
10
|
+
* key. Never throws and never blocks a request: callers run it in `after()`
|
|
11
|
+
* or at startup. Without a secret key it's a no-op.
|
|
12
|
+
*
|
|
13
|
+
* Uses node:http, not `fetch`: Next patches `fetch` during prerender, and an
|
|
14
|
+
* uncached fetch there counts as dynamic usage (the push would fail at build,
|
|
15
|
+
* where static-mode regions are rendered).
|
|
16
|
+
*/
|
|
17
|
+
export declare function pushToCloud(path: "/v1/manifest" | "/v1/regions", body: unknown, opts?: {
|
|
18
|
+
env?: Env;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
}): Promise<PushResult>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import "../store/runtime.js";
|
|
2
|
+
import { request } from "node:http";
|
|
3
|
+
import { request as request$1 } from "node:https";
|
|
4
|
+
// src/cloud/push.ts
|
|
5
|
+
/**
|
|
6
|
+
* POSTs configuration (manifest, regions) to the cloud with the site's secret
|
|
7
|
+
* key. Never throws and never blocks a request: callers run it in `after()`
|
|
8
|
+
* or at startup. Without a secret key it's a no-op.
|
|
9
|
+
*
|
|
10
|
+
* Uses node:http, not `fetch`: Next patches `fetch` during prerender, and an
|
|
11
|
+
* uncached fetch there counts as dynamic usage (the push would fail at build,
|
|
12
|
+
* where static-mode regions are rendered).
|
|
13
|
+
*/
|
|
14
|
+
function pushToCloud(path, body, opts = {}) {
|
|
15
|
+
const env = opts.env ?? process.env;
|
|
16
|
+
const key = env.EVOLVE_SECRET_KEY;
|
|
17
|
+
const cloud = env.EVOLVE_CLOUD_URL ?? "https://api.evolveab.com";
|
|
18
|
+
if (!key || !cloud) return Promise.resolve({
|
|
19
|
+
ok: false,
|
|
20
|
+
status: "no_key"
|
|
21
|
+
});
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(path, cloud);
|
|
25
|
+
const data = Buffer.from(JSON.stringify(body));
|
|
26
|
+
const req = (url.protocol === "https:" ? request$1 : request)(url, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: {
|
|
29
|
+
authorization: `Bearer ${key}`,
|
|
30
|
+
"content-type": "application/json",
|
|
31
|
+
"content-length": data.length
|
|
32
|
+
},
|
|
33
|
+
timeout: opts.timeoutMs ?? 3e3
|
|
34
|
+
}, (res) => {
|
|
35
|
+
res.resume();
|
|
36
|
+
const status = res.statusCode ?? 0;
|
|
37
|
+
resolve(status >= 200 && status < 300 ? { ok: true } : {
|
|
38
|
+
ok: false,
|
|
39
|
+
status
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
req.on("timeout", () => req.destroy(/* @__PURE__ */ new Error("timeout")));
|
|
43
|
+
req.on("error", () => resolve({
|
|
44
|
+
ok: false,
|
|
45
|
+
status: "network"
|
|
46
|
+
}));
|
|
47
|
+
req.end(data);
|
|
48
|
+
} catch {
|
|
49
|
+
resolve({
|
|
50
|
+
ok: false,
|
|
51
|
+
status: "network"
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export { pushToCloud };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { RegionRegistration } from "@next-evolve/protocol";
|
|
2
|
+
/**
|
|
3
|
+
* Registers a region with the cloud (spec §5.2), once per process per
|
|
4
|
+
* (id, schema, path). Errors are swallowed; a failed push is retried on a later render.
|
|
5
|
+
*/
|
|
6
|
+
export declare function registerRegion(r: Omit<RegionRegistration, "pv">): Promise<void>;
|
|
7
|
+
/** Test helper. */
|
|
8
|
+
export declare function resetRegionRegistry(): void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { pushToCloud } from "./push.js";
|
|
2
|
+
import { PROTOCOL_VERSION } from "@next-evolve/protocol";
|
|
3
|
+
// src/cloud/regions.ts
|
|
4
|
+
const sent = /* @__PURE__ */ new Set();
|
|
5
|
+
const MAX_SENT = 1e3;
|
|
6
|
+
/**
|
|
7
|
+
* Registers a region with the cloud (spec §5.2), once per process per
|
|
8
|
+
* (id, schema, path). Errors are swallowed; a failed push is retried on a later render.
|
|
9
|
+
*/
|
|
10
|
+
async function registerRegion(r) {
|
|
11
|
+
const key = `${r.id}|${r.schemaHash}|${r.path}`;
|
|
12
|
+
if (sent.has(key)) return;
|
|
13
|
+
sent.add(key);
|
|
14
|
+
if (sent.size > MAX_SENT) sent.delete(sent.values().next().value);
|
|
15
|
+
const res = await pushToCloud("/v1/regions", {
|
|
16
|
+
pv: PROTOCOL_VERSION,
|
|
17
|
+
...r
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok && res.status !== "no_key") sent.delete(key);
|
|
20
|
+
}
|
|
21
|
+
/** Test helper. */
|
|
22
|
+
function resetRegionRegistry() {
|
|
23
|
+
sent.clear();
|
|
24
|
+
}
|
|
25
|
+
export { registerRegion, resetRegionRegistry };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type RouteConfig = {
|
|
2
|
+
/** static (default): ISR per variant under `app/[evolve]/`. dynamic: page reads `x-evolve-a`. */
|
|
3
|
+
mode: "static" | "dynamic";
|
|
4
|
+
};
|
|
5
|
+
export type EvolveConfig = {
|
|
6
|
+
/** Routes Evolve may test, keyed by exact pathname (e.g. "/", "/pricing"). */
|
|
7
|
+
routes: Record<string, RouteConfig>;
|
|
8
|
+
store?: "auto" | "memory" | "global-config";
|
|
9
|
+
sync?: {
|
|
10
|
+
pollSeconds?: number;
|
|
11
|
+
};
|
|
12
|
+
database?: {
|
|
13
|
+
urlEnv?: string;
|
|
14
|
+
};
|
|
15
|
+
aggregate?: {
|
|
16
|
+
maxMs?: number;
|
|
17
|
+
statementTimeoutMs?: number;
|
|
18
|
+
};
|
|
19
|
+
cookies?: {
|
|
20
|
+
maxAgeDays?: number;
|
|
21
|
+
domain?: string;
|
|
22
|
+
};
|
|
23
|
+
/** Return false to serve control and set no cookies (e.g. before consent). */
|
|
24
|
+
consent?: (req: Request) => boolean;
|
|
25
|
+
bots?: {
|
|
26
|
+
extraPatterns?: string[];
|
|
27
|
+
};
|
|
28
|
+
debug?: boolean;
|
|
29
|
+
};
|
|
30
|
+
export type ResolvedConfig = {
|
|
31
|
+
routes: ReadonlyMap<string, RouteConfig>;
|
|
32
|
+
store: "auto" | "memory" | "global-config";
|
|
33
|
+
pollSeconds: number;
|
|
34
|
+
databaseUrlEnv: string;
|
|
35
|
+
aggregate: {
|
|
36
|
+
maxMs: number;
|
|
37
|
+
statementTimeoutMs: number;
|
|
38
|
+
};
|
|
39
|
+
cookies: {
|
|
40
|
+
maxAgeDays: number;
|
|
41
|
+
domain: string | undefined;
|
|
42
|
+
};
|
|
43
|
+
consent: ((req: Request) => boolean) | undefined;
|
|
44
|
+
botPatterns: readonly string[];
|
|
45
|
+
debug: boolean;
|
|
46
|
+
};
|
|
47
|
+
/** Normalizes a pathname: no trailing slash (except "/"), no empty segments. */
|
|
48
|
+
export declare function normalizePath(p: string): string;
|
|
49
|
+
/** Identity helper for `evolve.config.ts` with types and validation. */
|
|
50
|
+
export declare function defineConfig<const C extends EvolveConfig>(config: C): C;
|
|
51
|
+
export declare function resolveConfig(config: EvolveConfig): ResolvedConfig;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/config/config.ts
|
|
2
|
+
/** Normalizes a pathname: no trailing slash (except "/"), no empty segments. */
|
|
3
|
+
function normalizePath(p) {
|
|
4
|
+
if (p === "/" || p === "") return "/";
|
|
5
|
+
const s = p.replace(/\/{2,}/g, "/");
|
|
6
|
+
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
7
|
+
}
|
|
8
|
+
/** Identity helper for `evolve.config.ts` with types and validation. */
|
|
9
|
+
function defineConfig(config) {
|
|
10
|
+
resolveConfig(config);
|
|
11
|
+
return config;
|
|
12
|
+
}
|
|
13
|
+
const resolved = /* @__PURE__ */ new WeakMap();
|
|
14
|
+
function resolveConfig(config) {
|
|
15
|
+
const hit = resolved.get(config);
|
|
16
|
+
if (hit) return hit;
|
|
17
|
+
const routes = /* @__PURE__ */ new Map();
|
|
18
|
+
for (const [raw, route] of Object.entries(config.routes)) {
|
|
19
|
+
if (!raw.startsWith("/")) throw new Error(`[evolve] route "${raw}" must start with "/"`);
|
|
20
|
+
const path = normalizePath(raw);
|
|
21
|
+
if (path.split("/")[1]?.startsWith("ev_")) throw new Error(`[evolve] route "${raw}" can't start with ev_`);
|
|
22
|
+
if (/[[\]*?#]/.test(path)) throw new Error(`[evolve] route "${raw}" must be a concrete path`);
|
|
23
|
+
if (route.mode !== "static" && route.mode !== "dynamic") throw new Error(`[evolve] route "${raw}": mode must be "static" or "dynamic"`);
|
|
24
|
+
routes.set(path, route);
|
|
25
|
+
}
|
|
26
|
+
const dev = process.env.NODE_ENV !== "production";
|
|
27
|
+
const r = {
|
|
28
|
+
routes,
|
|
29
|
+
store: config.store ?? "auto",
|
|
30
|
+
pollSeconds: config.sync?.pollSeconds ?? (dev ? 5 : 60),
|
|
31
|
+
databaseUrlEnv: config.database?.urlEnv ?? "EVOLVE_DATABASE_URL",
|
|
32
|
+
aggregate: {
|
|
33
|
+
maxMs: config.aggregate?.maxMs ?? 25e3,
|
|
34
|
+
statementTimeoutMs: config.aggregate?.statementTimeoutMs ?? 2e4
|
|
35
|
+
},
|
|
36
|
+
cookies: {
|
|
37
|
+
maxAgeDays: config.cookies?.maxAgeDays ?? 365,
|
|
38
|
+
domain: config.cookies?.domain
|
|
39
|
+
},
|
|
40
|
+
consent: config.consent,
|
|
41
|
+
botPatterns: config.bots?.extraPatterns ?? [],
|
|
42
|
+
debug: config.debug ?? dev
|
|
43
|
+
};
|
|
44
|
+
resolved.set(config, r);
|
|
45
|
+
return r;
|
|
46
|
+
}
|
|
47
|
+
export { defineConfig, normalizePath, resolveConfig };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { SerializedGoal } from "@next-evolve/protocol";
|
|
2
|
+
/** `<n>m`, `<n>h` or `<n>d`, at most 365 days. */
|
|
3
|
+
export type GoalWindow = `${number}${"m" | "h" | "d"}`;
|
|
4
|
+
type Common = {
|
|
5
|
+
/** Attribution window after first exposure. Default `7d`. */
|
|
6
|
+
window?: GoalWindow;
|
|
7
|
+
/** Count per visitor (default) or per identified user. */
|
|
8
|
+
unit?: "visitor" | "user";
|
|
9
|
+
/** Whether higher is better. Default `increase`. */
|
|
10
|
+
direction?: "increase" | "decrease";
|
|
11
|
+
};
|
|
12
|
+
/** An event as a goal's `value` function sees it (at write time, in your app). */
|
|
13
|
+
export type GoalEvent = {
|
|
14
|
+
name: string;
|
|
15
|
+
props: Record<string, unknown>;
|
|
16
|
+
value: number | null;
|
|
17
|
+
source: string;
|
|
18
|
+
userId: string | null;
|
|
19
|
+
anonId: string | null;
|
|
20
|
+
};
|
|
21
|
+
export type EventGoal = Common & {
|
|
22
|
+
event: string;
|
|
23
|
+
/** Only events from this source (e.g. `stripe`). */
|
|
24
|
+
source?: string;
|
|
25
|
+
/** Only events whose props equal these values. */
|
|
26
|
+
where?: Record<string, string | number | boolean>;
|
|
27
|
+
/** A numeric prop path (`"amount"`, `"order.total"`) or a function run locally at write time. */
|
|
28
|
+
value?: string | ((e: GoalEvent) => number | null | undefined);
|
|
29
|
+
};
|
|
30
|
+
export type PageviewGoal = Common & {
|
|
31
|
+
/** Exact path, or a prefix ending in `*` (e.g. `/docs/*`). */
|
|
32
|
+
pageview: string;
|
|
33
|
+
};
|
|
34
|
+
export type GoalDefinition = EventGoal | PageviewGoal;
|
|
35
|
+
export type Goals = Record<string, GoalDefinition>;
|
|
36
|
+
/** Identity helper for `evolve.goals.ts`: types plus validation (throws on mistakes). */
|
|
37
|
+
export declare function defineGoals<const G extends Goals>(goals: G): G;
|
|
38
|
+
/**
|
|
39
|
+
* Goal definitions as sent to the cloud. Function values become
|
|
40
|
+
* `{ kind: "local-fn" }`; the function itself never leaves the app.
|
|
41
|
+
*/
|
|
42
|
+
export declare function serializeGoals(goals: Goals): Record<string, SerializedGoal>;
|
|
43
|
+
/** True if a stored `$pageview` at `path` would count for some pageview goal. */
|
|
44
|
+
export declare function matchesPageviewGoal(goals: Goals | undefined, path: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Runs the `value` functions of matching event goals. Returns `{ goalKey: n }`
|
|
47
|
+
* or null. A function that throws or returns a non-finite number is skipped.
|
|
48
|
+
*/
|
|
49
|
+
export declare function computeGoalValues(goals: Goals | undefined, e: GoalEvent): Record<string, number> | null;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { logRateLimited } from "../core/log.js";
|
|
2
|
+
import { GOAL_KEY_RE, serializedGoalSchema } from "@next-evolve/protocol";
|
|
3
|
+
// src/config/goals.ts
|
|
4
|
+
/** Identity helper for `evolve.goals.ts`: types plus validation (throws on mistakes). */
|
|
5
|
+
function defineGoals(goals) {
|
|
6
|
+
serializeGoals(goals);
|
|
7
|
+
return goals;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Goal definitions as sent to the cloud. Function values become
|
|
11
|
+
* `{ kind: "local-fn" }`; the function itself never leaves the app.
|
|
12
|
+
*/
|
|
13
|
+
function serializeGoals(goals) {
|
|
14
|
+
const out = {};
|
|
15
|
+
for (const [key, def] of Object.entries(goals)) {
|
|
16
|
+
if (!GOAL_KEY_RE.test(key)) throw new Error(`[evolve] goal key "${key}" must match ${GOAL_KEY_RE}`);
|
|
17
|
+
if ("sql" in def) throw new Error(`[evolve] goal "${key}": SQL goals aren't supported yet`);
|
|
18
|
+
const common = {
|
|
19
|
+
window: def.window ?? "7d",
|
|
20
|
+
unit: def.unit ?? "visitor",
|
|
21
|
+
direction: def.direction ?? "increase"
|
|
22
|
+
};
|
|
23
|
+
let wire;
|
|
24
|
+
if ("event" in def && "pageview" in def) throw new Error(`[evolve] goal "${key}": use either event or pageview, not both`);
|
|
25
|
+
else if ("event" in def) wire = {
|
|
26
|
+
kind: "event",
|
|
27
|
+
event: def.event,
|
|
28
|
+
source: def.source ?? null,
|
|
29
|
+
where: def.where ?? null,
|
|
30
|
+
value: typeof def.value === "function" ? { kind: "local-fn" } : typeof def.value === "string" ? {
|
|
31
|
+
kind: "prop",
|
|
32
|
+
path: def.value
|
|
33
|
+
} : null,
|
|
34
|
+
...common
|
|
35
|
+
};
|
|
36
|
+
else if ("pageview" in def) wire = {
|
|
37
|
+
kind: "pageview",
|
|
38
|
+
path: def.pageview,
|
|
39
|
+
value: null,
|
|
40
|
+
...common
|
|
41
|
+
};
|
|
42
|
+
else throw new Error(`[evolve] goal "${key}" needs event or pageview`);
|
|
43
|
+
const r = serializedGoalSchema.safeParse(wire);
|
|
44
|
+
if (!r.success) {
|
|
45
|
+
const what = r.error.issues.map((i) => `${i.path.join(".") || "goal"}: ${i.message}`);
|
|
46
|
+
throw new Error(`[evolve] goal "${key}" is invalid (${what.join("; ")})`);
|
|
47
|
+
}
|
|
48
|
+
out[key] = r.data;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/** True if a stored `$pageview` at `path` would count for some pageview goal. */
|
|
53
|
+
function matchesPageviewGoal(goals, path) {
|
|
54
|
+
if (!goals) return false;
|
|
55
|
+
for (const def of Object.values(goals)) {
|
|
56
|
+
if (!("pageview" in def)) continue;
|
|
57
|
+
const p = def.pageview;
|
|
58
|
+
if (p.endsWith("*") ? path.startsWith(p.slice(0, -1)) : path === p) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Runs the `value` functions of matching event goals. Returns `{ goalKey: n }`
|
|
64
|
+
* or null. A function that throws or returns a non-finite number is skipped.
|
|
65
|
+
*/
|
|
66
|
+
function computeGoalValues(goals, e) {
|
|
67
|
+
if (!goals) return null;
|
|
68
|
+
let out = null;
|
|
69
|
+
for (const [key, def] of Object.entries(goals)) {
|
|
70
|
+
if (!("event" in def) || typeof def.value !== "function" || def.event !== e.name) continue;
|
|
71
|
+
if (def.source !== void 0 && def.source !== e.source) continue;
|
|
72
|
+
if (def.where && !Object.entries(def.where).every(([k, v]) => e.props[k] === v)) continue;
|
|
73
|
+
try {
|
|
74
|
+
const v = def.value(e);
|
|
75
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
76
|
+
out ??= {};
|
|
77
|
+
out[key] = v;
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
logRateLimited(`goal:${key}`, `goal "${key}": value function threw`, err);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
export { computeGoalValues, defineGoals, matchesPageviewGoal, serializeGoals };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { EvolveConfig, ResolvedConfig, RouteConfig, defineConfig, resolveConfig } from "./config.js";
|
|
2
|
+
import { EventGoal, GoalDefinition, GoalEvent, GoalWindow, Goals, PageviewGoal, defineGoals, serializeGoals } from "./goals.js";
|
|
3
|
+
export { type EventGoal, type EvolveConfig, type GoalDefinition, type GoalEvent, type GoalWindow, type Goals, type PageviewGoal, type ResolvedConfig, type RouteConfig, defineConfig, defineGoals, resolveConfig, serializeGoals };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { BundlePayload, CodePair } from "@next-evolve/protocol";
|
|
2
|
+
type Variant = {
|
|
3
|
+
id: string;
|
|
4
|
+
w: number;
|
|
5
|
+
};
|
|
6
|
+
/** Picks a variant id for r ∈ [0, 1) proportionally to weight (w > 0 only). */
|
|
7
|
+
export declare function weightedPick(variants: readonly Variant[], r: number): string;
|
|
8
|
+
/**
|
|
9
|
+
* A draw in [0, 1) for a key, e.g. `<exp>:t` (traffic) and `<exp>:v` (variant).
|
|
10
|
+
* `Math.random` fits too (it ignores the key).
|
|
11
|
+
*/
|
|
12
|
+
export type Draw = (key: string) => number;
|
|
13
|
+
/**
|
|
14
|
+
* Deterministic draws for one visitor: the same `evolve_id` and experiment give
|
|
15
|
+
* the same assignment on every request, so concurrent first requests (a link
|
|
16
|
+
* prefetch and the click that follows) can't assign different variants.
|
|
17
|
+
* FNV-1a with a murmur3 finalizer; uniform enough for traffic splits, not crypto.
|
|
18
|
+
*/
|
|
19
|
+
export declare function visitorDraw(anonId: string): Draw;
|
|
20
|
+
export type Assignment = {
|
|
21
|
+
/** Pairs for the variant code (unsorted; `encodeCode` sorts). */
|
|
22
|
+
pairs: CodePair[];
|
|
23
|
+
/** Assignments to write back to `evolve_a`. */
|
|
24
|
+
assigned: Map<string, string>;
|
|
25
|
+
/** Whether `assigned` differs from the cookie that came in. */
|
|
26
|
+
changed: boolean;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Assigns a visitor to the experiments on `path` (spec §6.3). Pure: no I/O.
|
|
30
|
+
* Sticky: an active assignment is kept. Completed experiments render their
|
|
31
|
+
* winner and aren't counted. Cookie entries for experiments no longer in the
|
|
32
|
+
* bundle are pruned.
|
|
33
|
+
*/
|
|
34
|
+
export declare function assign(bundle: BundlePayload, path: string, cookie: ReadonlyMap<string, string>, rand: Draw): Assignment;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import "./cookies.js";
|
|
2
|
+
// src/core/assign.ts
|
|
3
|
+
/** Picks a variant id for r ∈ [0, 1) proportionally to weight (w > 0 only). */
|
|
4
|
+
function weightedPick(variants, r) {
|
|
5
|
+
let total = 0;
|
|
6
|
+
for (const v of variants) if (v.w > 0) total += v.w;
|
|
7
|
+
let acc = 0;
|
|
8
|
+
let last = variants[0].id;
|
|
9
|
+
for (const v of variants) {
|
|
10
|
+
if (v.w <= 0) continue;
|
|
11
|
+
acc += v.w / total;
|
|
12
|
+
last = v.id;
|
|
13
|
+
if (r < acc) return v.id;
|
|
14
|
+
}
|
|
15
|
+
return last;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Deterministic draws for one visitor: the same `evolve_id` and experiment give
|
|
19
|
+
* the same assignment on every request, so concurrent first requests (a link
|
|
20
|
+
* prefetch and the click that follows) can't assign different variants.
|
|
21
|
+
* FNV-1a with a murmur3 finalizer; uniform enough for traffic splits, not crypto.
|
|
22
|
+
*/
|
|
23
|
+
function visitorDraw(anonId) {
|
|
24
|
+
return (key) => {
|
|
25
|
+
let h = 2166136261;
|
|
26
|
+
const s = `${anonId}|${key}`;
|
|
27
|
+
for (let i = 0; i < s.length; i++) {
|
|
28
|
+
h ^= s.charCodeAt(i);
|
|
29
|
+
h = Math.imul(h, 16777619);
|
|
30
|
+
}
|
|
31
|
+
h ^= h >>> 16;
|
|
32
|
+
h = Math.imul(h, 2246822507);
|
|
33
|
+
h ^= h >>> 13;
|
|
34
|
+
h = Math.imul(h, 3266489909);
|
|
35
|
+
h ^= h >>> 16;
|
|
36
|
+
return (h >>> 0) / 4294967296;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Assigns a visitor to the experiments on `path` (spec §6.3). Pure: no I/O.
|
|
41
|
+
* Sticky: an active assignment is kept. Completed experiments render their
|
|
42
|
+
* winner and aren't counted. Cookie entries for experiments no longer in the
|
|
43
|
+
* bundle are pruned.
|
|
44
|
+
*/
|
|
45
|
+
function assign(bundle, path, cookie, rand) {
|
|
46
|
+
const { experiments, routes } = bundle.routing;
|
|
47
|
+
const assigned = /* @__PURE__ */ new Map();
|
|
48
|
+
let changed = false;
|
|
49
|
+
for (const [exp, v] of cookie) if (experiments[exp]) assigned.set(exp, v);
|
|
50
|
+
else changed = true;
|
|
51
|
+
const pairs = [];
|
|
52
|
+
for (const exp of routes[path] ?? []) {
|
|
53
|
+
const e = experiments[exp];
|
|
54
|
+
if (!e) continue;
|
|
55
|
+
if (e.status === "completed") {
|
|
56
|
+
pairs.push({
|
|
57
|
+
exp,
|
|
58
|
+
var: e.winner,
|
|
59
|
+
counted: false
|
|
60
|
+
});
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const a = assigned.get(exp);
|
|
64
|
+
if (a === "x") {
|
|
65
|
+
pairs.push({
|
|
66
|
+
exp,
|
|
67
|
+
var: e.control,
|
|
68
|
+
counted: false
|
|
69
|
+
});
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (a !== void 0 && e.variants.some((v) => v.id === a && v.w > 0)) {
|
|
73
|
+
pairs.push({
|
|
74
|
+
exp,
|
|
75
|
+
var: a,
|
|
76
|
+
counted: true
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
changed = true;
|
|
81
|
+
if (rand(`${exp}:t`) >= e.traffic) {
|
|
82
|
+
assigned.set(exp, "x");
|
|
83
|
+
pairs.push({
|
|
84
|
+
exp,
|
|
85
|
+
var: e.control,
|
|
86
|
+
counted: false
|
|
87
|
+
});
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const v = weightedPick(e.variants, rand(`${exp}:v`));
|
|
91
|
+
assigned.set(exp, v);
|
|
92
|
+
pairs.push({
|
|
93
|
+
exp,
|
|
94
|
+
var: v,
|
|
95
|
+
counted: true
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
pairs,
|
|
100
|
+
assigned,
|
|
101
|
+
changed
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export { assign, visitorDraw, weightedPick };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a detector: `isbot` + empty user agent + configured `extraPatterns`
|
|
3
|
+
* (regex sources, case-insensitive). Bots get `ev_0` and no cookies.
|
|
4
|
+
*/
|
|
5
|
+
export declare function createBotDetector(extraPatterns?: readonly string[]): (ua: string | null | undefined) => boolean;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { isbot } from "isbot";
|
|
2
|
+
// src/core/bots.ts
|
|
3
|
+
/** Unfurlers and tools `isbot` may not flag; all served control and never counted. */
|
|
4
|
+
const EXTRA = [
|
|
5
|
+
/whatsapp/i,
|
|
6
|
+
/telegrambot/i,
|
|
7
|
+
/chrome-lighthouse/i,
|
|
8
|
+
/headlesschrome/i,
|
|
9
|
+
/vercelbot/i,
|
|
10
|
+
/claude-user/i
|
|
11
|
+
];
|
|
12
|
+
/**
|
|
13
|
+
* Returns a detector: `isbot` + empty user agent + configured `extraPatterns`
|
|
14
|
+
* (regex sources, case-insensitive). Bots get `ev_0` and no cookies.
|
|
15
|
+
*/
|
|
16
|
+
function createBotDetector(extraPatterns = []) {
|
|
17
|
+
const extra = [...EXTRA, ...extraPatterns.map((p) => new RegExp(p, "i"))];
|
|
18
|
+
return (ua) => {
|
|
19
|
+
if (!ua?.trim()) return true;
|
|
20
|
+
if (isbot(ua)) return true;
|
|
21
|
+
for (const re of extra) if (re.test(ua)) return true;
|
|
22
|
+
return false;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export { createBotDetector };
|