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,182 @@
1
+ import { logRateLimited } from "../core/log.js";
2
+ import { isServerless } from "./client.js";
3
+ // src/db/writer.ts
4
+ const CHUNK = 1e3;
5
+ const MAX_BACKOFF_MS = 3e4;
6
+ function createWriter(opts) {
7
+ const mode = opts.mode ?? (isServerless() ? "serverless" : "node");
8
+ const flushMs = opts.flushMs ?? 1e3;
9
+ const flushRows = opts.flushRows ?? 100;
10
+ const maxBuffer = opts.maxBuffer ?? 1e4;
11
+ let exposures = [];
12
+ let events = [];
13
+ let identities = [];
14
+ let usage = /* @__PURE__ */ new Map();
15
+ let timer;
16
+ let backoffMs = 0;
17
+ let running = Promise.resolve();
18
+ const pending = () => exposures.length + events.length + identities.length + usage.size;
19
+ function schedule(delay) {
20
+ if (mode !== "node" || timer) return;
21
+ timer = setTimeout(() => {
22
+ timer = void 0;
23
+ flush();
24
+ }, delay);
25
+ timer.unref?.();
26
+ }
27
+ function added() {
28
+ if (mode !== "node") return;
29
+ trim();
30
+ if (pending() >= flushRows && backoffMs === 0) flush();
31
+ else schedule(backoffMs || flushMs);
32
+ }
33
+ /** Keeps the buffer bounded while the DB is down: the oldest rows go first. */
34
+ function trim() {
35
+ let over = pending() - maxBuffer;
36
+ if (over <= 0) return;
37
+ logRateLimited("writer:drop", `customer DB unavailable; dropping ${over} buffered rows`);
38
+ for (const list of [
39
+ events,
40
+ exposures,
41
+ identities
42
+ ]) {
43
+ const n = Math.min(over, list.length);
44
+ list.splice(0, n);
45
+ over -= n;
46
+ }
47
+ }
48
+ async function write(sql, batch) {
49
+ for (const rows of chunks(batch.exposures)) await sql`insert into evolve.exposures ${sql(rows, "experiment_id", "anon_id", "variant_id", "path")}
50
+ on conflict do nothing`;
51
+ for (const rows of chunks(batch.events)) await sql`insert into evolve.events ${sql(rows.map((r) => ({
52
+ ...r,
53
+ goal_values: r.goal_values && sql.json(r.goal_values),
54
+ props: r.props && sql.json(r.props)
55
+ })), EVENT_COLS)}
56
+ on conflict (dedupe_key) where dedupe_key is not null do nothing`;
57
+ for (const rows of chunks(batch.identities)) await sql`insert into evolve.identities ${sql(rows, "anon_id", "user_id")} on conflict do nothing`;
58
+ const days = [...batch.usage].map(([day, n]) => ({
59
+ day,
60
+ tested_pageviews: n
61
+ }));
62
+ if (days.length) await sql`insert into evolve.usage_daily ${sql(days, "day", "tested_pageviews")}
63
+ on conflict (day) do update
64
+ set tested_pageviews = evolve.usage_daily.tested_pageviews + excluded.tested_pageviews`;
65
+ }
66
+ function take() {
67
+ const b = {
68
+ exposures,
69
+ events,
70
+ identities,
71
+ usage
72
+ };
73
+ exposures = [];
74
+ events = [];
75
+ identities = [];
76
+ usage = /* @__PURE__ */ new Map();
77
+ return b;
78
+ }
79
+ function restore(b) {
80
+ exposures = [...b.exposures, ...exposures];
81
+ events = [...b.events, ...events];
82
+ identities = [...b.identities, ...identities];
83
+ for (const [d, n] of b.usage) usage.set(d, (usage.get(d) ?? 0) + n);
84
+ }
85
+ async function flushOnce() {
86
+ if (pending() === 0) return;
87
+ const sql = opts.sql();
88
+ if (!sql) {
89
+ logRateLimited("writer:nodb", "EVOLVE_DATABASE_URL is not set; events are not stored");
90
+ take();
91
+ return;
92
+ }
93
+ const batch = take();
94
+ try {
95
+ await write(sql, batch);
96
+ backoffMs = 0;
97
+ } catch (e) {
98
+ if (mode === "serverless") try {
99
+ await write(sql, batch);
100
+ return;
101
+ } catch (e2) {
102
+ logRateLimited("writer:dropped", "customer DB write failed; batch dropped", e2);
103
+ return;
104
+ }
105
+ logRateLimited("writer:retry", "customer DB write failed; will retry", e);
106
+ restore(batch);
107
+ trim();
108
+ backoffMs = Math.min(MAX_BACKOFF_MS, backoffMs ? backoffMs * 2 : flushMs);
109
+ schedule(backoffMs);
110
+ }
111
+ }
112
+ function flush() {
113
+ if (timer) {
114
+ clearTimeout(timer);
115
+ timer = void 0;
116
+ }
117
+ running = running.then(flushOnce, flushOnce);
118
+ return running;
119
+ }
120
+ return {
121
+ mode,
122
+ exposures(rows) {
123
+ if (!rows.length) return;
124
+ exposures.push(...rows);
125
+ added();
126
+ },
127
+ events(rows) {
128
+ if (!rows.length) return;
129
+ events.push(...rows);
130
+ added();
131
+ },
132
+ identities(rows) {
133
+ if (!rows.length) return;
134
+ identities.push(...rows);
135
+ added();
136
+ },
137
+ usage(day, n) {
138
+ if (n <= 0) return;
139
+ usage.set(day, (usage.get(day) ?? 0) + n);
140
+ added();
141
+ },
142
+ pending,
143
+ flush
144
+ };
145
+ }
146
+ const EVENT_COLS = [
147
+ "ts",
148
+ "name",
149
+ "anon_id",
150
+ "user_id",
151
+ "value",
152
+ "goal_values",
153
+ "props",
154
+ "source",
155
+ "path",
156
+ "dedupe_key"
157
+ ];
158
+ function* chunks(rows) {
159
+ for (let i = 0; i < rows.length; i += CHUNK) yield rows.slice(i, i + CHUNK);
160
+ }
161
+ const WRITERS = Symbol.for("next-evolve.writers");
162
+ /**
163
+ * The process-wide writer for a DB URL variable. On long-running Node it also
164
+ * flushes on SIGTERM so a graceful shutdown doesn't lose buffered rows.
165
+ */
166
+ function getWriter(sql, key = "EVOLVE_DATABASE_URL") {
167
+ const g = globalThis;
168
+ g[WRITERS] ??= /* @__PURE__ */ new Map();
169
+ let w = g[WRITERS].get(key);
170
+ if (!w) {
171
+ const writer = createWriter({ sql });
172
+ w = writer;
173
+ g[WRITERS].set(key, writer);
174
+ if (!isServerless() && typeof process !== "undefined" && process.once) process.once("SIGTERM", () => {
175
+ writer.flush().finally(() => {
176
+ if (process.listenerCount("SIGTERM") === 0) process.exit(0);
177
+ });
178
+ });
179
+ }
180
+ return w;
181
+ }
182
+ export { createWriter, getWriter };
@@ -0,0 +1,5 @@
1
+ import { OptimizableComponent, OptimizableMeta, getOptimizableMeta, optimizable } from "./react/optimizable.js";
2
+ import { Optimizable, OptimizableProps } from "./react/region.js";
3
+ import { EvolvePageProps, evolveMetadata, evolveStaticParams, withEvolve } from "./react/with-evolve.js";
4
+ import "server-only";
5
+ export { type EvolvePageProps, Optimizable, type OptimizableComponent, type OptimizableMeta, type OptimizableProps, evolveMetadata, evolveStaticParams, getOptimizableMeta, optimizable, withEvolve };
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ import { getOptimizableMeta, optimizable } from "./react/optimizable.js";
2
+ import { Optimizable } from "./react/region.js";
3
+ import { evolveMetadata, evolveStaticParams, withEvolve } from "./react/with-evolve.js";
4
+ import "server-only";
5
+ export { Optimizable, evolveMetadata, evolveStaticParams, getOptimizableMeta, optimizable, withEvolve };
@@ -0,0 +1,23 @@
1
+ import { EvolveConfig } from "../config/config.js";
2
+ import { Goals } from "../config/goals.js";
3
+ export type RegisterEvolveOptions = {
4
+ config: EvolveConfig;
5
+ goals?: Goals;
6
+ };
7
+ /**
8
+ * Call from `instrumentation.ts` `register()`:
9
+ *
10
+ * export async function register() {
11
+ * if (process.env.NEXT_RUNTIME === "nodejs") {
12
+ * const { registerEvolve } = await import("next-evolve/instrumentation");
13
+ * await registerEvolve({ config, goals });
14
+ * }
15
+ * }
16
+ *
17
+ * Registers the goals for this process and pushes the manifest to the cloud
18
+ * (goals, routes, versions, app URL; spec §8.4), once per process. Never
19
+ * throws. Background polling and the disk cache arrive with T7.4.
20
+ */
21
+ export declare function registerEvolve(opts: RegisterEvolveOptions): Promise<void>;
22
+ /** Test helper. */
23
+ export declare function resetRegisterEvolve(): void;
@@ -0,0 +1,39 @@
1
+ import { buildManifest, detectAppUrl } from "../cloud/manifest.js";
2
+ import { pushToCloud } from "../cloud/push.js";
3
+ import { setRuntime } from "../server/runtime.js";
4
+ // src/instrumentation/index.ts
5
+ let registered = false;
6
+ /**
7
+ * Call from `instrumentation.ts` `register()`:
8
+ *
9
+ * export async function register() {
10
+ * if (process.env.NEXT_RUNTIME === "nodejs") {
11
+ * const { registerEvolve } = await import("next-evolve/instrumentation");
12
+ * await registerEvolve({ config, goals });
13
+ * }
14
+ * }
15
+ *
16
+ * Registers the goals for this process and pushes the manifest to the cloud
17
+ * (goals, routes, versions, app URL; spec §8.4), once per process. Never
18
+ * throws. Background polling and the disk cache arrive with T7.4.
19
+ */
20
+ async function registerEvolve(opts) {
21
+ if (registered) return;
22
+ registered = true;
23
+ try {
24
+ setRuntime({
25
+ config: opts.config,
26
+ goals: opts.goals
27
+ });
28
+ const manifest = buildManifest(opts.config, opts.goals, { appUrl: detectAppUrl() });
29
+ const res = await pushToCloud("/v1/manifest", manifest);
30
+ if (!res.ok && res.status !== "no_key") console.error(`[evolve] manifest push failed (${res.status})`);
31
+ } catch (e) {
32
+ console.error("[evolve] registerEvolve failed", e);
33
+ }
34
+ }
35
+ /** Test helper. */
36
+ function resetRegisterEvolve() {
37
+ registered = false;
38
+ }
39
+ export { registerEvolve, resetRegisterEvolve };
@@ -0,0 +1,2 @@
1
+ import { EvolveProxyOptions, applyEvolve, createEvolveProxy } from "./proxy.js";
2
+ export { type EvolveProxyOptions, applyEvolve, createEvolveProxy };
@@ -0,0 +1 @@
1
+ export { applyEvolve, createEvolveProxy } from "./proxy.js";
@@ -0,0 +1,18 @@
1
+ import { EvolveConfig } from "../config/config.js";
2
+ import { RuntimeOptions } from "../store/runtime.js";
3
+ import { NextFetchEvent, NextRequest, NextResponse } from "next/server";
4
+ export type EvolveProxyOptions = RuntimeOptions & {
5
+ /** Random source in [0, 1) (tests). */
6
+ random?: () => number;
7
+ };
8
+ /** Query parameter carrying a preview token from the dashboard. */
9
+ export declare const PREVIEW_PARAM = "evolve_preview";
10
+ /**
11
+ * Runs Evolve for one request. Returns a response for Evolve routes and
12
+ * `/ev_*` paths, or null for everything else (so it composes with an existing
13
+ * proxy: `return applyEvolve(config, req, event) ?? NextResponse.next()`).
14
+ * Never throws and never makes a network call on the request path.
15
+ */
16
+ export declare function applyEvolve(config: EvolveConfig, req: NextRequest, event?: Pick<NextFetchEvent, "waitUntil">, opts?: EvolveProxyOptions): NextResponse | null;
17
+ /** Builds the `proxy` export for `proxy.ts` (Next 16) or `middleware.ts` (Next 15.5, Node runtime). */
18
+ export declare function createEvolveProxy(config: EvolveConfig, opts?: EvolveProxyOptions): (req: NextRequest, event: NextFetchEvent) => NextResponse;
@@ -0,0 +1,103 @@
1
+ import { normalizePath, resolveConfig } from "../config/config.js";
2
+ import { assign } from "../core/assign.js";
3
+ import { createBotDetector } from "../core/bots.js";
4
+ import { HEADER_CODE, HEADER_PATH } from "../core/constants.js";
5
+ import { ANON_COOKIE, ASSIGN_COOKIE, PREVIEW_COOKIE, parseAssignments, serializeAssignments } from "../core/cookies.js";
6
+ import { logRateLimited } from "../core/log.js";
7
+ import { getBundleStore, getSiteIdentity } from "../store/runtime.js";
8
+ import { CONTROL_CODE, encodeCode, newAnonId, verifyPreview } from "@next-evolve/protocol";
9
+ import { NextResponse } from "next/server";
10
+ // src/proxy/proxy.ts
11
+ /** Query parameter carrying a preview token from the dashboard. */
12
+ const PREVIEW_PARAM = "evolve_preview";
13
+ const detectors = /* @__PURE__ */ new WeakMap();
14
+ /**
15
+ * Runs Evolve for one request. Returns a response for Evolve routes and
16
+ * `/ev_*` paths, or null for everything else (so it composes with an existing
17
+ * proxy: `return applyEvolve(config, req, event) ?? NextResponse.next()`).
18
+ * Never throws and never makes a network call on the request path.
19
+ */
20
+ function applyEvolve(config, req, event, opts = {}) {
21
+ const t0 = performance.now();
22
+ const pathname = req.nextUrl.pathname;
23
+ if (pathname.split("/")[1]?.startsWith("ev_")) return new NextResponse(null, { status: 404 });
24
+ let cfg;
25
+ try {
26
+ cfg = resolveConfig(config);
27
+ } catch (e) {
28
+ logRateLimited("config", "invalid evolve config", e);
29
+ return null;
30
+ }
31
+ const path = normalizePath(pathname);
32
+ const route = cfg.routes.get(path);
33
+ if (!route) return null;
34
+ const isStatic = route.mode === "static";
35
+ let res;
36
+ try {
37
+ let isBot = detectors.get(config);
38
+ if (!isBot) {
39
+ isBot = createBotDetector(cfg.botPatterns);
40
+ detectors.set(config, isBot);
41
+ }
42
+ const bundle = getBundleStore(cfg, opts)?.peek(event ? (p) => event.waitUntil(p) : void 0) ?? null;
43
+ let code = CONTROL_CODE;
44
+ const setCookies = [];
45
+ const fromQuery = req.nextUrl.searchParams.get(PREVIEW_PARAM);
46
+ const previewToken = fromQuery ?? req.cookies.get("evolve_pv")?.value;
47
+ const identity = previewToken ? getSiteIdentity(opts) : null;
48
+ const preview = previewToken && identity ? verifyPreview(previewToken, identity) : null;
49
+ if (preview) {
50
+ code = preview.code;
51
+ if (fromQuery) setCookies.push([
52
+ PREVIEW_COOKIE,
53
+ fromQuery,
54
+ true
55
+ ]);
56
+ } else if (bundle && !isBot(req.headers.get("user-agent")) && cfg.consent?.(req) !== false) {
57
+ const cookie = parseAssignments(req.cookies.get(ASSIGN_COOKIE)?.value);
58
+ const a = assign(bundle, path, cookie, opts.random ?? Math.random);
59
+ code = encodeCode(a.pairs);
60
+ if ((bundle.routing.routes[path] ?? []).some((e) => bundle.routing.experiments[e]?.status === "running")) {
61
+ if (!req.cookies.get("evolve_id")?.value) setCookies.push([ANON_COOKIE, newAnonId()]);
62
+ if (a.changed) setCookies.push([ASSIGN_COOKIE, serializeAssignments(a.assigned)]);
63
+ }
64
+ }
65
+ res = isStatic ? rewrite(req, code, path) : passThrough(req, code, path);
66
+ if (setCookies.length) {
67
+ const secure = (req.headers.get("x-forwarded-proto") ?? req.nextUrl.protocol.replace(":", "")) === "https";
68
+ const maxAge = cfg.cookies.maxAgeDays * 86400;
69
+ for (const [name, value, session] of setCookies) res.cookies.set(name, value, {
70
+ path: "/",
71
+ ...session ? {} : { maxAge },
72
+ sameSite: "lax",
73
+ httpOnly: true,
74
+ secure,
75
+ ...cfg.cookies.domain ? { domain: cfg.cookies.domain } : {}
76
+ });
77
+ }
78
+ } catch (e) {
79
+ logRateLimited("proxy", "proxy error; serving control", e);
80
+ res = isStatic ? rewrite(req, CONTROL_CODE, path) : passThrough(req, CONTROL_CODE, path);
81
+ }
82
+ if ((opts.env ?? process.env).VERCEL !== "1") res.headers.set("cache-control", "private, no-store");
83
+ res.headers.set("server-timing", `evolve;dur=${(performance.now() - t0).toFixed(3)}`);
84
+ return res;
85
+ }
86
+ function rewrite(req, code, path) {
87
+ const target = path === "/" ? `/${code}` : `/${code}${path}`;
88
+ return NextResponse.rewrite(new URL(target + req.nextUrl.search, req.url));
89
+ }
90
+ function passThrough(req, code, path) {
91
+ const headers = new Headers(req.headers);
92
+ headers.set(HEADER_CODE, code);
93
+ headers.set(HEADER_PATH, path);
94
+ return NextResponse.next({ request: { headers } });
95
+ }
96
+ /** Builds the `proxy` export for `proxy.ts` (Next 16) or `middleware.ts` (Next 15.5, Node runtime). */
97
+ function createEvolveProxy(config, opts = {}) {
98
+ resolveConfig(config);
99
+ return function proxy(req, event) {
100
+ return applyEvolve(config, req, event, opts) ?? NextResponse.next();
101
+ };
102
+ }
103
+ export { PREVIEW_PARAM, applyEvolve, createEvolveProxy };
@@ -0,0 +1,6 @@
1
+ export type EvolveRequestContext = {
2
+ code: string | null;
3
+ path: string | null;
4
+ };
5
+ /** Per-request store (React `cache()`), filled by `withEvolve` before the page body renders. */
6
+ export declare const requestContext: () => EvolveRequestContext;
@@ -0,0 +1,8 @@
1
+ import { cache } from "react";
2
+ // src/react/context.ts
3
+ /** Per-request store (React `cache()`), filled by `withEvolve` before the page body renders. */
4
+ const requestContext = cache(() => ({
5
+ code: null,
6
+ path: null
7
+ }));
8
+ export { requestContext };
@@ -0,0 +1,37 @@
1
+ import { ComponentType } from "react";
2
+ import { z } from "zod";
3
+ declare const META: unique symbol;
4
+ export type OptimizableMeta<S extends z.ZodObject = z.ZodObject> = {
5
+ schema: S;
6
+ /** JSON Schema of the editable props (dashboard forms; later the AI). Includes `.meta()`. */
7
+ jsonSchema: Record<string, unknown>;
8
+ /** sha256 of the canonical JSON Schema; changes when the schema changes. */
9
+ schemaHash: string;
10
+ keys: readonly string[];
11
+ };
12
+ export type OptimizableComponent<P extends object, S extends z.ZodObject = z.ZodObject> = ComponentType<P> & {
13
+ readonly [META]: OptimizableMeta<S>;
14
+ };
15
+ /**
16
+ * Compile-time check that the schema fits the component: every schema key is a
17
+ * prop the component accepts, with a compatible type.
18
+ */
19
+ type SchemaFits<P, O> = [Exclude<keyof O, keyof P>] extends [never] ? O extends Pick<P, keyof O & keyof P> ? unknown : {
20
+ "schema error": "schema prop types don't match the component's props";
21
+ } : {
22
+ "schema error": "schema has props the component doesn't accept";
23
+ keys: Exclude<keyof O, keyof P>;
24
+ };
25
+ /**
26
+ * Marks a component as optimizable with a Zod schema for the props Evolve may
27
+ * change. A schema that doesn't fit the component's props fails to compile.
28
+ * Server-only (the component renders inside `<Optimizable>`).
29
+ *
30
+ * ```ts
31
+ * export default optimizable(Hero, heroSchema);
32
+ * ```
33
+ */
34
+ export declare function optimizable<P extends object, S extends z.ZodObject>(component: ComponentType<P>, schema: S & SchemaFits<P, z.output<S>>): OptimizableComponent<P, S>;
35
+ export declare function getOptimizableMeta(c: unknown): OptimizableMeta | null;
36
+ export declare function isRegionId(id: string): boolean;
37
+ export {};
@@ -0,0 +1,38 @@
1
+ import { REGION_ID_RE, canonicalJson, sha256Hex } from "@next-evolve/protocol";
2
+ import { createElement } from "react";
3
+ import { z } from "zod";
4
+ // src/react/optimizable.ts
5
+ const META = Symbol.for("next-evolve.optimizable");
6
+ /**
7
+ * Marks a component as optimizable with a Zod schema for the props Evolve may
8
+ * change. A schema that doesn't fit the component's props fails to compile.
9
+ * Server-only (the component renders inside `<Optimizable>`).
10
+ *
11
+ * ```ts
12
+ * export default optimizable(Hero, heroSchema);
13
+ * ```
14
+ */
15
+ function optimizable(component, schema) {
16
+ const jsonSchema = z.toJSONSchema(schema, {
17
+ io: "input",
18
+ unrepresentable: "any"
19
+ });
20
+ const meta = {
21
+ schema,
22
+ jsonSchema,
23
+ schemaHash: sha256Hex(canonicalJson(jsonSchema)),
24
+ keys: Object.keys(schema.shape)
25
+ };
26
+ const Wrapped = ((props) => createElement(component, props));
27
+ Object.defineProperty(Wrapped, META, { value: meta });
28
+ Object.defineProperty(Wrapped, "displayName", { value: `Optimizable(${component.displayName ?? component.name ?? "Component"})` });
29
+ Object.defineProperty(Wrapped, "inner", { value: component });
30
+ return Wrapped;
31
+ }
32
+ function getOptimizableMeta(c) {
33
+ return c?.[META] ?? null;
34
+ }
35
+ function isRegionId(id) {
36
+ return REGION_ID_RE.test(id);
37
+ }
38
+ export { getOptimizableMeta, isRegionId, optimizable };
@@ -0,0 +1,17 @@
1
+ import { OptimizableComponent } from "./optimizable.js";
2
+ import { ReactElement } from "react";
3
+ import { z } from "zod";
4
+ /** Control props: the schema's input type (defaults optional) plus the component's other props. */
5
+ export type ControlProps<P extends object, S extends z.ZodObject> = Omit<P, keyof z.output<S>> & z.input<S>;
6
+ export type OptimizableProps<P extends object, S extends z.ZodObject> = {
7
+ /** Region id, unique per site: /^[a-z0-9][a-z0-9-]{0,39}$/. */
8
+ id: string;
9
+ component: OptimizableComponent<P, S>;
10
+ /** Control props. Variant props are merged over these; schema defaults apply. */
11
+ props: ControlProps<P, S>;
12
+ };
13
+ /**
14
+ * Renders the variant of a region chosen by the proxy, or control. Never throws
15
+ * because of Evolve: any problem renders control. Server component.
16
+ */
17
+ export declare function Optimizable<P extends object, S extends z.ZodObject>({ id, component, props }: OptimizableProps<P, S>): Promise<ReactElement>;
@@ -0,0 +1,127 @@
1
+ import { registerRegion } from "../cloud/regions.js";
2
+ import { HEADER_CODE, HEADER_PATH } from "../core/constants.js";
3
+ import { logRateLimited } from "../core/log.js";
4
+ import { requestContext } from "./context.js";
5
+ import { getOptimizableMeta } from "./optimizable.js";
6
+ import { getRenderBundle } from "./render-bundle.js";
7
+ import { CONTROL_CODE, decodeCode } from "@next-evolve/protocol";
8
+ import { createElement } from "react";
9
+ import { headers } from "next/headers";
10
+ import { unstable_rethrow } from "next/navigation";
11
+ import { after } from "next/server";
12
+ // src/react/region.tsx
13
+ let warnedNoContext = false;
14
+ async function currentCode() {
15
+ const ctx = requestContext();
16
+ if (ctx.code) return ctx.code;
17
+ try {
18
+ return (await headers()).get(HEADER_CODE);
19
+ } catch (e) {
20
+ unstable_rethrow(e);
21
+ return null;
22
+ }
23
+ }
24
+ /** The page path this region renders on (static: withEvolve; dynamic: the proxy header). */
25
+ async function currentPath() {
26
+ const ctx = requestContext();
27
+ if (ctx.path) return ctx.path;
28
+ try {
29
+ return (await headers()).get(HEADER_PATH);
30
+ } catch (e) {
31
+ unstable_rethrow(e);
32
+ return null;
33
+ }
34
+ }
35
+ /** Region registration (spec §5.2): after the response, deduped per process, errors swallowed. */
36
+ function scheduleRegistration(id, meta, controlProps, path) {
37
+ if (!path) return;
38
+ try {
39
+ after(() => registerRegion({
40
+ id,
41
+ jsonSchema: meta.jsonSchema,
42
+ schemaHash: meta.schemaHash,
43
+ controlProps,
44
+ path
45
+ }));
46
+ } catch {}
47
+ }
48
+ function findPair(bundle, pairs, region) {
49
+ for (const p of pairs) {
50
+ const v = bundle?.variants[p.var];
51
+ if (v && v.exp === p.exp && v.region === region) return {
52
+ pair: p,
53
+ variant: v
54
+ };
55
+ }
56
+ return null;
57
+ }
58
+ /**
59
+ * Renders the variant of a region chosen by the proxy, or control. Never throws
60
+ * because of Evolve: any problem renders control. Server component.
61
+ */
62
+ async function Optimizable({ id, component, props }) {
63
+ const meta = getOptimizableMeta(component);
64
+ if (!meta) {
65
+ logRateLimited(`optimizable:${id}`, `<Optimizable id="${id}">: component isn't wrapped with optimizable()`);
66
+ return createElement(component, props);
67
+ }
68
+ const editableOf = (o) => {
69
+ const out = {};
70
+ for (const k of meta.keys) if (k in o) out[k] = o[k];
71
+ return out;
72
+ };
73
+ const parsedControl = meta.schema.safeParse(editableOf(props));
74
+ const controlProps = {
75
+ ...props,
76
+ ...parsedControl.success ? parsedControl.data : {}
77
+ };
78
+ const control = () => createElement(component, controlProps);
79
+ const code = await currentCode();
80
+ scheduleRegistration(id, meta, parsedControl.success ? parsedControl.data : editableOf(props), await currentPath());
81
+ if (!code) {
82
+ if (!warnedNoContext && process.env.NODE_ENV !== "production") {
83
+ warnedNoContext = true;
84
+ console.warn(`[evolve] <Optimizable id="${id}"> rendered without Evolve context; serving control. Wrap the page with withEvolve() under app/[evolve]/, or use a dynamic route.`);
85
+ }
86
+ return control();
87
+ }
88
+ if (code === CONTROL_CODE) return control();
89
+ const pairs = decodeCode(code);
90
+ if (!pairs || pairs.length === 0) return control();
91
+ let bundle = await getRenderBundle();
92
+ let hit = findPair(bundle, pairs, id);
93
+ const unknown = () => pairs.some((p) => !bundle?.variants[p.var]);
94
+ if (!hit && unknown()) {
95
+ bundle = await getRenderBundle({ fresh: true });
96
+ hit = findPair(bundle, pairs, id);
97
+ }
98
+ const marker = (attrs, child) => createElement("div", {
99
+ style: { display: "contents" },
100
+ "data-evolve-r": id,
101
+ ...attrs
102
+ }, child);
103
+ if (!hit) return unknown() ? marker({ "data-evolve-x": "" }, control()) : control();
104
+ const { pair, variant } = hit;
105
+ const attrs = {
106
+ "data-evolve-e": pair.exp,
107
+ "data-evolve-v": pair.var
108
+ };
109
+ if (!pair.counted) attrs["data-evolve-x"] = "";
110
+ if (variant.ctrl || Object.keys(variant.props).length === 0) return marker(attrs, control());
111
+ const parsed = meta.schema.safeParse(editableOf({
112
+ ...props,
113
+ ...variant.props
114
+ }));
115
+ if (!parsed.success) {
116
+ logRateLimited(`variant:${pair.var}`, `variant ${pair.var} of region "${id}" failed its schema; serving control`, parsed.error.issues);
117
+ return marker({
118
+ ...attrs,
119
+ "data-evolve-x": ""
120
+ }, control());
121
+ }
122
+ return marker(attrs, createElement(component, {
123
+ ...props,
124
+ ...parsed.data
125
+ }));
126
+ }
127
+ export { Optimizable };
@@ -0,0 +1,9 @@
1
+ import { BundlePayload } from "@next-evolve/protocol";
2
+ /**
3
+ * Bundle for rendering. Phase 1 (M1): the process-wide store shared with the
4
+ * proxy; `fresh` awaits a refetch first. T7.6 adds the tagged fetch for hosts
5
+ * where render and proxy run in different processes.
6
+ */
7
+ export declare function getRenderBundle(opts?: {
8
+ fresh?: boolean;
9
+ }): Promise<BundlePayload | null>;
@@ -0,0 +1,14 @@
1
+ import { getBundleStore } from "../store/runtime.js";
2
+ // src/react/render-bundle.ts
3
+ /**
4
+ * Bundle for rendering. Phase 1 (M1): the process-wide store shared with the
5
+ * proxy; `fresh` awaits a refetch first. T7.6 adds the tagged fetch for hosts
6
+ * where render and proxy run in different processes.
7
+ */
8
+ async function getRenderBundle(opts = {}) {
9
+ const store = getBundleStore({});
10
+ if (!store) return null;
11
+ if (opts.fresh) await store.refresh();
12
+ return store.peek();
13
+ }
14
+ export { getRenderBundle };