next-evolve 0.0.0-stage → 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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +82 -2
  3. package/dist/cli/index.d.ts +1 -0
  4. package/dist/cli/index.js +40 -0
  5. package/dist/client/beacon.d.ts +11 -0
  6. package/dist/client/beacon.js +51 -0
  7. package/dist/client/evolve-script.d.ts +6 -0
  8. package/dist/client/evolve-script.js +60 -0
  9. package/dist/client/index.d.ts +3 -0
  10. package/dist/client/index.js +3 -0
  11. package/dist/cloud/manifest.d.ts +12 -0
  12. package/dist/cloud/manifest.js +42 -0
  13. package/dist/cloud/push.d.ts +21 -0
  14. package/dist/cloud/push.js +56 -0
  15. package/dist/cloud/regions.d.ts +8 -0
  16. package/dist/cloud/regions.js +25 -0
  17. package/dist/config/config.d.ts +51 -0
  18. package/dist/config/config.js +47 -0
  19. package/dist/config/goals.d.ts +50 -0
  20. package/dist/config/goals.js +85 -0
  21. package/dist/config/index.d.ts +3 -0
  22. package/dist/config/index.js +3 -0
  23. package/dist/core/assign.d.ts +23 -0
  24. package/dist/core/assign.js +82 -0
  25. package/dist/core/bots.d.ts +5 -0
  26. package/dist/core/bots.js +25 -0
  27. package/dist/core/constants.d.ts +3 -0
  28. package/dist/core/constants.js +5 -0
  29. package/dist/core/cookies.d.ts +12 -0
  30. package/dist/core/cookies.js +25 -0
  31. package/dist/core/keys.d.ts +14 -0
  32. package/dist/core/keys.js +38 -0
  33. package/dist/core/log.d.ts +2 -0
  34. package/dist/core/log.js +10 -0
  35. package/dist/db/client.d.ts +22 -0
  36. package/dist/db/client.js +50 -0
  37. package/dist/db/migrate.d.ts +18 -0
  38. package/dist/db/migrate.js +43 -0
  39. package/dist/db/writer.d.ts +51 -0
  40. package/dist/db/writer.js +182 -0
  41. package/dist/index.d.ts +5 -0
  42. package/dist/index.js +5 -0
  43. package/dist/instrumentation/index.d.ts +23 -0
  44. package/dist/instrumentation/index.js +39 -0
  45. package/dist/proxy/index.d.ts +2 -0
  46. package/dist/proxy/index.js +1 -0
  47. package/dist/proxy/proxy.d.ts +18 -0
  48. package/dist/proxy/proxy.js +103 -0
  49. package/dist/react/context.d.ts +6 -0
  50. package/dist/react/context.js +8 -0
  51. package/dist/react/optimizable.d.ts +37 -0
  52. package/dist/react/optimizable.js +38 -0
  53. package/dist/react/region.d.ts +17 -0
  54. package/dist/react/region.js +127 -0
  55. package/dist/react/render-bundle.d.ts +9 -0
  56. package/dist/react/render-bundle.js +14 -0
  57. package/dist/react/with-evolve.d.ts +29 -0
  58. package/dist/react/with-evolve.js +57 -0
  59. package/dist/server/collect.d.ts +46 -0
  60. package/dist/server/collect.js +131 -0
  61. package/dist/server/events.d.ts +18 -0
  62. package/dist/server/events.js +50 -0
  63. package/dist/server/evolve.d.ts +20 -0
  64. package/dist/server/evolve.js +57 -0
  65. package/dist/server/handler.d.ts +16 -0
  66. package/dist/server/handler.js +72 -0
  67. package/dist/server/index.d.ts +4 -0
  68. package/dist/server/index.js +4 -0
  69. package/dist/server/runtime.d.ts +26 -0
  70. package/dist/server/runtime.js +45 -0
  71. package/dist/server/track-route.d.ts +25 -0
  72. package/dist/server/track-route.js +49 -0
  73. package/dist/store/fetch.d.ts +22 -0
  74. package/dist/store/fetch.js +39 -0
  75. package/dist/store/memory.d.ts +30 -0
  76. package/dist/store/memory.js +68 -0
  77. package/dist/store/runtime.d.ts +30 -0
  78. package/dist/store/runtime.js +69 -0
  79. package/migrations/0001_init.sql +56 -0
  80. package/package.json +72 -3
@@ -0,0 +1,22 @@
1
+ export type FetchBundleResult = {
2
+ status: "ok";
3
+ token: string;
4
+ etag: string | null;
5
+ } | {
6
+ status: "not_modified";
7
+ } | {
8
+ status: "error";
9
+ code: number | "network";
10
+ message?: string;
11
+ };
12
+ /**
13
+ * `GET {cloud}/v1/bundle` with the secret key and `If-None-Match` (spec §7.5).
14
+ * Never throws. Verification is the caller's job.
15
+ */
16
+ export declare function fetchBundle(opts: {
17
+ cloudUrl: string;
18
+ secretKey: string;
19
+ etag?: string | null;
20
+ timeoutMs?: number;
21
+ fetch?: typeof fetch;
22
+ }): Promise<FetchBundleResult>;
@@ -0,0 +1,39 @@
1
+ // src/store/fetch.ts
2
+ /**
3
+ * `GET {cloud}/v1/bundle` with the secret key and `If-None-Match` (spec §7.5).
4
+ * Never throws. Verification is the caller's job.
5
+ */
6
+ async function fetchBundle(opts) {
7
+ const f = opts.fetch ?? fetch;
8
+ try {
9
+ const res = await f(new URL("/v1/bundle", opts.cloudUrl), {
10
+ headers: {
11
+ authorization: `Bearer ${opts.secretKey}`,
12
+ ...opts.etag ? { "if-none-match": opts.etag } : {}
13
+ },
14
+ cache: "no-store",
15
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 5e3)
16
+ });
17
+ if (res.status === 304) return { status: "not_modified" };
18
+ if (!res.ok) {
19
+ const body = await res.text().catch(() => "");
20
+ return {
21
+ status: "error",
22
+ code: res.status,
23
+ message: body.slice(0, 200)
24
+ };
25
+ }
26
+ return {
27
+ status: "ok",
28
+ token: (await res.text()).trim(),
29
+ etag: res.headers.get("etag")
30
+ };
31
+ } catch (e) {
32
+ return {
33
+ status: "error",
34
+ code: "network",
35
+ message: String(e)
36
+ };
37
+ }
38
+ }
39
+ export { fetchBundle };
@@ -0,0 +1,30 @@
1
+ import { FetchBundleResult } from "./fetch.js";
2
+ import { BundlePayload, KeyRing } from "@next-evolve/protocol";
3
+ export type BundleStore = {
4
+ /**
5
+ * Synchronous read for the request path: the current verified, unexpired
6
+ * bundle or null. May start a background refresh, handed to `waitUntil`.
7
+ */
8
+ peek(waitUntil?: (p: Promise<unknown>) => void): BundlePayload | null;
9
+ /** Verifies and installs a token (build bundle, disk, sync route). Returns whether it was accepted. */
10
+ load(token: string): boolean;
11
+ /** Fetches now (one in flight). Resolves once done. */
12
+ refresh(): Promise<void>;
13
+ /** Raw token of the current bundle (for persistence). */
14
+ token(): string | null;
15
+ };
16
+ export type MemoryStoreOptions = {
17
+ site: string;
18
+ keys: KeyRing;
19
+ /** Fetches from the cloud. Omit for a static store (build bundle only). */
20
+ fetcher?: (etag: string | null) => Promise<FetchBundleResult>;
21
+ pollMs: number;
22
+ initialToken?: string | null;
23
+ now?: () => number;
24
+ random?: () => number;
25
+ };
26
+ /**
27
+ * In-memory bundle store with stale-while-revalidate polling (spec §7.5):
28
+ * never blocks a request, one fetch in flight, ±10% jitter.
29
+ */
30
+ export declare function createMemoryStore(opts: MemoryStoreOptions): BundleStore;
@@ -0,0 +1,68 @@
1
+ import { logRateLimited } from "../core/log.js";
2
+ import { verifyBundle } from "@next-evolve/protocol";
3
+ // src/store/memory.ts
4
+ /**
5
+ * In-memory bundle store with stale-while-revalidate polling (spec §7.5):
6
+ * never blocks a request, one fetch in flight, ±10% jitter.
7
+ */
8
+ function createMemoryStore(opts) {
9
+ const now = opts.now ?? Date.now;
10
+ const random = opts.random ?? Math.random;
11
+ let current = null;
12
+ let currentToken = null;
13
+ let etag = null;
14
+ let nextCheck = 0;
15
+ let inflight = null;
16
+ const schedule = () => {
17
+ nextCheck = now() + opts.pollMs * (.9 + .2 * random());
18
+ };
19
+ function load(token) {
20
+ const r = verifyBundle(token, {
21
+ keys: opts.keys,
22
+ site: opts.site,
23
+ current: current ? {
24
+ ver: current.ver,
25
+ exp: current.exp
26
+ } : null,
27
+ now: Math.floor(now() / 1e3)
28
+ });
29
+ if (!r.ok) {
30
+ if (r.reason !== "rollback") logRateLimited(`bundle:${r.reason}`, `bundle rejected: ${r.reason}`);
31
+ return false;
32
+ }
33
+ current = r.payload;
34
+ currentToken = token;
35
+ etag = `"${r.payload.ver}.${r.payload.iat}"`;
36
+ return true;
37
+ }
38
+ function refresh() {
39
+ if (!opts.fetcher) return Promise.resolve();
40
+ if (inflight) return inflight;
41
+ schedule();
42
+ inflight = opts.fetcher(etag).then((res) => {
43
+ if (res.status === "ok") load(res.token);
44
+ else if (res.status === "error") logRateLimited(`fetch:${res.code}`, `bundle fetch failed (${res.code})`, res.message);
45
+ }).catch((e) => logRateLimited("fetch:throw", "bundle fetch failed", e)).finally(() => {
46
+ inflight = null;
47
+ });
48
+ return inflight;
49
+ }
50
+ if (opts.initialToken) load(opts.initialToken);
51
+ return {
52
+ peek(waitUntil) {
53
+ if (current && now() >= current.exp * 1e3) {
54
+ current = null;
55
+ currentToken = null;
56
+ }
57
+ if (opts.fetcher && !inflight && now() >= nextCheck) {
58
+ const p = refresh();
59
+ waitUntil?.(p);
60
+ }
61
+ return current;
62
+ },
63
+ load,
64
+ refresh,
65
+ token: () => currentToken
66
+ };
67
+ }
68
+ export { createMemoryStore };
@@ -0,0 +1,30 @@
1
+ import { BundleStore } from "./memory.js";
2
+ import { KeyRing } from "@next-evolve/protocol";
3
+ type Env = Record<string, string | undefined>;
4
+ export type RuntimeOptions = {
5
+ /** Signed bundle written at build time by `evolve pull` (cold-start fallback). */
6
+ buildBundle?: string | null;
7
+ /** Inject a store (tests). */
8
+ store?: BundleStore;
9
+ env?: Env;
10
+ };
11
+ /** Default cloud URL (decision D29). Override with EVOLVE_CLOUD_URL. */
12
+ export declare const DEFAULT_CLOUD_URL: string | undefined;
13
+ /**
14
+ * The process-wide bundle store for this site, shared by the proxy and the
15
+ * render path when they run in the same process. Null when the app has no
16
+ * valid secret key (then everything serves control).
17
+ */
18
+ export declare function getBundleStore(config: {
19
+ pollSeconds?: number;
20
+ }, opts?: RuntimeOptions): BundleStore | null;
21
+ /** This app's site id (from EVOLVE_SECRET_KEY) and trusted public keys. Cached; null without a valid key. */
22
+ export declare function getSiteIdentity(opts?: {
23
+ env?: Env;
24
+ }): {
25
+ site: string;
26
+ keys: KeyRing;
27
+ } | null;
28
+ /** Test helper: forget all stores. */
29
+ export declare function resetBundleStores(): void;
30
+ export {};
@@ -0,0 +1,69 @@
1
+ import { trustedKeys } from "../core/keys.js";
2
+ import { fetchBundle } from "./fetch.js";
3
+ import { createMemoryStore } from "./memory.js";
4
+ import { parseSecretKey } from "@next-evolve/protocol";
5
+ // src/store/runtime.ts
6
+ /** Default cloud URL (decision D29). Override with EVOLVE_CLOUD_URL. */
7
+ const DEFAULT_CLOUD_URL = "https://api.evolveab.com";
8
+ const REGISTRY = Symbol.for("next-evolve.stores");
9
+ const registry = () => {
10
+ const g = globalThis;
11
+ g[REGISTRY] ??= /* @__PURE__ */ new Map();
12
+ return g[REGISTRY];
13
+ };
14
+ /**
15
+ * The process-wide bundle store for this site, shared by the proxy and the
16
+ * render path when they run in the same process. Null when the app has no
17
+ * valid secret key (then everything serves control).
18
+ */
19
+ function getBundleStore(config, opts = {}) {
20
+ if (opts.store) return opts.store;
21
+ const env = opts.env ?? process.env;
22
+ const secretKey = env.EVOLVE_SECRET_KEY;
23
+ const site = secretKey ? parseSecretKey(secretKey)?.siteId : void 0;
24
+ if (!secretKey || !site) return null;
25
+ const reg = registry();
26
+ const existing = reg.get(site);
27
+ if (existing) {
28
+ if (opts.buildBundle && !existing.token()) existing.load(opts.buildBundle);
29
+ return existing;
30
+ }
31
+ const cloudUrl = env.EVOLVE_CLOUD_URL ?? "https://api.evolveab.com";
32
+ const store = createMemoryStore({
33
+ site,
34
+ keys: trustedKeys(env),
35
+ pollMs: (config.pollSeconds ?? (env.NODE_ENV === "production" ? 60 : 5)) * 1e3,
36
+ initialToken: opts.buildBundle ?? null,
37
+ fetcher: cloudUrl ? (etag) => fetchBundle({
38
+ cloudUrl,
39
+ secretKey,
40
+ etag
41
+ }) : void 0
42
+ });
43
+ reg.set(site, store);
44
+ return store;
45
+ }
46
+ const identities = /* @__PURE__ */ new Map();
47
+ /** This app's site id (from EVOLVE_SECRET_KEY) and trusted public keys. Cached; null without a valid key. */
48
+ function getSiteIdentity(opts = {}) {
49
+ const env = opts.env ?? process.env;
50
+ const cacheKey = [
51
+ env.EVOLVE_SECRET_KEY,
52
+ env.EVOLVE_UNSAFE_TEST_PUBLIC_KEY,
53
+ env.EVOLVE_UNSAFE_ALLOW_TEST_KEY,
54
+ env.NODE_ENV
55
+ ].join("|");
56
+ if (identities.has(cacheKey)) return identities.get(cacheKey);
57
+ const site = env.EVOLVE_SECRET_KEY ? parseSecretKey(env.EVOLVE_SECRET_KEY)?.siteId : void 0;
58
+ const id = site ? {
59
+ site,
60
+ keys: trustedKeys(env)
61
+ } : null;
62
+ identities.set(cacheKey, id);
63
+ return id;
64
+ }
65
+ /** Test helper: forget all stores. */
66
+ function resetBundleStores() {
67
+ registry().clear();
68
+ }
69
+ export { DEFAULT_CLOUD_URL, getBundleStore, getSiteIdentity, resetBundleStores };
@@ -0,0 +1,56 @@
1
+ -- next-evolve customer schema (spec §8.3). Raw events stay in this database.
2
+ -- Applied by `npx next-evolve migrate`; the migrator creates `evolve.migrations`.
3
+
4
+ -- One row per unit per experiment (first exposure).
5
+ create table evolve.exposures (
6
+ experiment_id text not null,
7
+ anon_id text not null,
8
+ variant_id text not null,
9
+ first_at timestamptz not null default now(),
10
+ path text not null,
11
+ segment text, -- null = main; Phase 3: UTM rule id
12
+ primary key (experiment_id, anon_id)
13
+ );
14
+ create index exposures_exp_first on evolve.exposures (experiment_id, first_at);
15
+
16
+ create table evolve.events (
17
+ id bigint generated always as identity primary key,
18
+ ts timestamptz not null,
19
+ received_at timestamptz not null default now(),
20
+ name text not null,
21
+ anon_id text,
22
+ user_id text,
23
+ value double precision,
24
+ goal_values jsonb,
25
+ props jsonb,
26
+ source text not null,
27
+ path text,
28
+ dedupe_key text
29
+ );
30
+ create index events_name_ts on evolve.events (name, ts);
31
+ create index events_anon_name on evolve.events (anon_id, name, ts) where anon_id is not null;
32
+ create index events_user_name on evolve.events (user_id, name, ts) where user_id is not null;
33
+ create unique index events_dedupe on evolve.events (dedupe_key) where dedupe_key is not null;
34
+
35
+ create table evolve.identities (
36
+ anon_id text primary key,
37
+ user_id text not null,
38
+ linked_at timestamptz not null default now()
39
+ );
40
+ create index identities_user on evolve.identities (user_id);
41
+
42
+ create table evolve.usage_daily (
43
+ day date primary key,
44
+ tested_pageviews bigint not null default 0
45
+ );
46
+
47
+ -- Idempotency cache + audit of what aggregate requests returned (kept 24 h).
48
+ create table evolve.aggregate_runs (
49
+ request_id text primary key,
50
+ received_at timestamptz not null,
51
+ mode text not null,
52
+ status text not null,
53
+ duration_ms int,
54
+ response_hash text,
55
+ response jsonb
56
+ );
package/package.json CHANGED
@@ -1,6 +1,75 @@
1
1
  {
2
2
  "name": "next-evolve",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Self-improving Next.js sites: server-side variants with no flicker, measured in your own Postgres.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "dist",
10
+ "migrations"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ },
17
+ "./config": {
18
+ "types": "./dist/config/index.d.ts",
19
+ "import": "./dist/config/index.js"
20
+ },
21
+ "./proxy": {
22
+ "types": "./dist/proxy/index.d.ts",
23
+ "import": "./dist/proxy/index.js"
24
+ },
25
+ "./server": {
26
+ "types": "./dist/server/index.d.ts",
27
+ "import": "./dist/server/index.js"
28
+ },
29
+ "./client": {
30
+ "types": "./dist/client/index.d.ts",
31
+ "import": "./dist/client/index.js"
32
+ },
33
+ "./instrumentation": {
34
+ "types": "./dist/instrumentation/index.d.ts",
35
+ "import": "./dist/instrumentation/index.js"
36
+ },
37
+ "./package.json": "./package.json"
38
+ },
39
+ "dependencies": {
40
+ "isbot": "5.2.2",
41
+ "postgres": "3.4.9",
42
+ "server-only": "^0.0.1",
43
+ "@next-evolve/protocol": "0.1.0"
44
+ },
45
+ "peerDependencies": {
46
+ "next": ">=15.5.0",
47
+ "react": "^19.0.0",
48
+ "zod": "^4.0.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/react": "^19.3.0",
52
+ "fast-check": "^4.10.2",
53
+ "next": "16.4.3",
54
+ "react": "19.3.1",
55
+ "react-dom": "19.3.1",
56
+ "zod": "^4.6.7",
57
+ "@next-evolve/fake-cloud": "0.0.0"
58
+ },
59
+ "engines": {
60
+ "node": ">=22.12"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ },
65
+ "bin": {
66
+ "next-evolve": "./dist/cli/index.js"
67
+ },
68
+ "scripts": {
69
+ "build": "tsdown && node scripts/check-dist.mjs && node scripts/check-proxy-imports.mjs && node scripts/check-size.mjs",
70
+ "dev": "tsdown --watch",
71
+ "typecheck": "tsc --noEmit",
72
+ "test": "vitest run --project unit",
73
+ "test:int": "vitest run --project integration"
74
+ }
6
75
  }