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.
- package/LICENSE +21 -0
- package/README.md +82 -2
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +40 -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 +12 -0
- package/dist/cloud/manifest.js +42 -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 +23 -0
- package/dist/core/assign.js +82 -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 +51 -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 +23 -0
- package/dist/instrumentation/index.js +39 -0
- package/dist/proxy/index.d.ts +2 -0
- package/dist/proxy/index.js +1 -0
- package/dist/proxy/proxy.d.ts +18 -0
- package/dist/proxy/proxy.js +103 -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 +9 -0
- package/dist/react/render-bundle.js +14 -0
- package/dist/react/with-evolve.d.ts +29 -0
- package/dist/react/with-evolve.js +57 -0
- package/dist/server/collect.d.ts +46 -0
- package/dist/server/collect.js +131 -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 +16 -0
- package/dist/server/handler.js +72 -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/track-route.d.ts +25 -0
- package/dist/server/track-route.js +49 -0
- package/dist/store/fetch.d.ts +22 -0
- package/dist/store/fetch.js +39 -0
- package/dist/store/memory.d.ts +30 -0
- package/dist/store/memory.js +68 -0
- package/dist/store/runtime.d.ts +30 -0
- package/dist/store/runtime.js +69 -0
- package/migrations/0001_init.sql +56 -0
- 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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
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,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 };
|