next-evolve 0.1.0 → 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.
Files changed (71) hide show
  1. package/README.md +55 -1
  2. package/dist/_virtual/_rolldown/runtime.js +12 -0
  3. package/dist/aggregate/attribution.d.ts +40 -0
  4. package/dist/aggregate/attribution.js +115 -0
  5. package/dist/aggregate/run.d.ts +21 -0
  6. package/dist/aggregate/run.js +139 -0
  7. package/dist/cli/aggregate.d.ts +9 -0
  8. package/dist/cli/aggregate.js +104 -0
  9. package/dist/cli/args.d.ts +12 -0
  10. package/dist/cli/args.js +23 -0
  11. package/dist/cli/cloud.d.ts +41 -0
  12. package/dist/cli/cloud.js +74 -0
  13. package/dist/cli/connect.d.ts +8 -0
  14. package/dist/cli/connect.js +46 -0
  15. package/dist/cli/detect.d.ts +26 -0
  16. package/dist/cli/detect.js +97 -0
  17. package/dist/cli/doctor.d.ts +34 -0
  18. package/dist/cli/doctor.js +230 -0
  19. package/dist/cli/env-file.d.ts +4 -0
  20. package/dist/cli/env-file.js +15 -0
  21. package/dist/cli/files.d.ts +25 -0
  22. package/dist/cli/files.js +173 -0
  23. package/dist/cli/index.js +73 -32
  24. package/dist/cli/init.d.ts +26 -0
  25. package/dist/cli/init.js +240 -0
  26. package/dist/cli/load-app.d.ts +19 -0
  27. package/dist/cli/load-app.js +67 -0
  28. package/dist/cli/migrate.d.ts +7 -0
  29. package/dist/cli/migrate.js +30 -0
  30. package/dist/cli/pages.d.ts +17 -0
  31. package/dist/cli/pages.js +304 -0
  32. package/dist/cli/plan.d.ts +27 -0
  33. package/dist/cli/plan.js +129 -0
  34. package/dist/cli/pull.d.ts +13 -0
  35. package/dist/cli/pull.js +62 -0
  36. package/dist/cli/push.d.ts +14 -0
  37. package/dist/cli/push.js +33 -0
  38. package/dist/cloud/manifest.d.ts +2 -0
  39. package/dist/cloud/manifest.js +6 -2
  40. package/dist/core/assign.d.ts +13 -1
  41. package/dist/core/assign.js +25 -3
  42. package/dist/db/writer.d.ts +2 -0
  43. package/dist/db/writer.js +1 -1
  44. package/dist/instrumentation/index.d.ts +3 -2
  45. package/dist/instrumentation/index.js +17 -2
  46. package/dist/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/typescript.d.ts +11169 -0
  47. package/dist/proxy/index.d.ts +2 -2
  48. package/dist/proxy/index.js +1 -1
  49. package/dist/proxy/proxy.d.ts +7 -1
  50. package/dist/proxy/proxy.js +23 -6
  51. package/dist/react/render-bundle.d.ts +10 -4
  52. package/dist/react/render-bundle.js +34 -5
  53. package/dist/server/aggregate-route.d.ts +16 -0
  54. package/dist/server/aggregate-route.js +68 -0
  55. package/dist/server/collect.d.ts +16 -0
  56. package/dist/server/collect.js +34 -8
  57. package/dist/server/handler.d.ts +3 -2
  58. package/dist/server/handler.js +37 -4
  59. package/dist/server/sync-route.d.ts +28 -0
  60. package/dist/server/sync-route.js +100 -0
  61. package/dist/store/disk.d.ts +6 -0
  62. package/dist/store/disk.js +23 -0
  63. package/dist/store/fetch.d.ts +4 -0
  64. package/dist/store/fetch.js +36 -1
  65. package/dist/store/global-config.d.ts +39 -0
  66. package/dist/store/global-config.js +122 -0
  67. package/dist/store/memory.d.ts +11 -2
  68. package/dist/store/memory.js +6 -2
  69. package/dist/store/runtime.d.ts +14 -0
  70. package/dist/store/runtime.js +39 -7
  71. package/package.json +2 -1
@@ -0,0 +1,304 @@
1
+ import { addImport } from "./files.js";
2
+ import { readdirSync, statSync } from "node:fs";
3
+ import { join, posix } from "node:path";
4
+ import { createRequire } from "node:module";
5
+ // src/cli/pages.ts
6
+ const PAGE_EXTS = [
7
+ "tsx",
8
+ "jsx",
9
+ "ts",
10
+ "js"
11
+ ];
12
+ const SPECIAL = /^(layout|template|loading|error|not-found|default)\.(tsx|jsx|ts|js)$/;
13
+ const ROUTE_FILE = /^(page|route)\.(tsx|jsx|ts|js|mdx)$/;
14
+ /** The app's typescript, else the one next to next-evolve; null when neither exists. */
15
+ function loadTypescript(root) {
16
+ for (const from of [join(root, "package.json"), import.meta.url]) try {
17
+ return createRequire(from)("typescript");
18
+ } catch {}
19
+ return null;
20
+ }
21
+ function list(root, dir) {
22
+ try {
23
+ const entries = readdirSync(join(root, dir));
24
+ const files = [];
25
+ const dirs = [];
26
+ for (const e of entries) {
27
+ const full = join(root, dir, e);
28
+ (statSync(full).isDirectory() ? dirs : files).push(e);
29
+ }
30
+ return {
31
+ files,
32
+ dirs
33
+ };
34
+ } catch {
35
+ return {
36
+ files: [],
37
+ dirs: []
38
+ };
39
+ }
40
+ }
41
+ /** Whether any folder below `dir` holds a page or route handler. */
42
+ function hasChildRoutes(root, dir) {
43
+ for (const sub of list(root, dir).dirs) {
44
+ const p = `${dir}/${sub}`;
45
+ if (list(root, p).files.some((f) => ROUTE_FILE.test(f)) || hasChildRoutes(root, p)) return true;
46
+ }
47
+ return false;
48
+ }
49
+ /** Finds `seg/page.*` inside some route group, for the note. */
50
+ function findInGroup(root, dir, seg) {
51
+ for (const sub of list(root, dir).dirs) {
52
+ if (!/^\(.+\)$/.test(sub)) continue;
53
+ const p = `${dir}/${sub}`;
54
+ if (PAGE_EXTS.map((e) => `${p}/${seg ? `${seg}/` : ""}page.${e}`).find((f) => list(root, posix.dirname(f)).files.includes(posix.basename(f)))) return sub;
55
+ const deeper = findInGroup(root, p, seg);
56
+ if (deeper) return deeper;
57
+ }
58
+ return null;
59
+ }
60
+ function planPages(plan, d, routes) {
61
+ const app = d.appDir;
62
+ if (!app || !routes.some((r) => r.mode === "static")) return routes;
63
+ const dynamicRoot = list(plan.root, app).dirs.find((x) => /^\[.+\]$/.test(x) && x !== "[evolve]");
64
+ const ts = loadTypescript(plan.root);
65
+ return routes.map((r) => {
66
+ if (r.mode !== "static") return r;
67
+ const keep = (why) => {
68
+ plan.notes.push(`${r.path}: kept dynamic, ${why}`);
69
+ return {
70
+ ...r,
71
+ mode: "dynamic"
72
+ };
73
+ };
74
+ if (dynamicRoot) return keep(`${app}/${dynamicRoot} conflicts with ${app}/[evolve] (static mode needs the top-level dynamic segment); "dynamic" mode works`);
75
+ const seg = r.path.replace(/^\/|\/$/g, "");
76
+ const from = seg ? `${app}/${seg}` : app;
77
+ const to = seg ? `${app}/[evolve]/${seg}` : `${app}/[evolve]`;
78
+ const pageName = PAGE_EXTS.map((e) => `page.${e}`).find((f) => plan.exists(`${from}/${f}`));
79
+ const moved = PAGE_EXTS.map((e) => `${to}/page.${e}`).find((f) => plan.exists(f));
80
+ if (!pageName) {
81
+ if (moved && plan.read(moved).includes("withEvolve")) return r;
82
+ const group = findInGroup(plan.root, app, seg);
83
+ return keep(group ? `the page is inside the route group ${group}; move it under ${app}/[evolve]/ by hand for static mode` : `no page found at ${from}/page.*`);
84
+ }
85
+ if (moved) return keep(`${moved} already exists`);
86
+ if (!ts) return keep(`typescript isn't installed, so the page can't be moved safely`);
87
+ const src = plan.read(`${from}/${pageName}`);
88
+ const rewritten = wrapPage(ts, src, pageName, r.path);
89
+ if (!rewritten.ok) return keep(rewritten.why);
90
+ const files = list(plan.root, from).files;
91
+ let movers = [pageName];
92
+ if (seg) {
93
+ if (hasChildRoutes(plan.root, from)) {
94
+ const special = files.find((f) => SPECIAL.test(f));
95
+ if (special) return keep(`${from}/${special} also wraps its child routes`);
96
+ } else movers = files;
97
+ }
98
+ const map = new Map(movers.map((f) => [`${from}/${f}`, `${to}/${f}`]));
99
+ for (const [oldPath, newPath] of map) {
100
+ const content = oldPath.endsWith(`/${pageName}`) ? rewritten.code : plan.read(oldPath);
101
+ const code = /\.(tsx?|jsx?|mjs|cjs)$/.test(oldPath) ? rewriteImports(ts, content, oldPath, newPath, map) : content;
102
+ plan.write(newPath, code);
103
+ plan.remove(oldPath);
104
+ }
105
+ plan.notes.push(`${r.path}: moved ${from}/${pageName} → ${to}/${pageName} (static mode)`);
106
+ return r;
107
+ });
108
+ }
109
+ const applyEdits = (src, edits) => [...edits].sort((a, b) => b.start - a.start).reduce((s, e) => s.slice(0, e.start) + e.text + s.slice(e.end), src);
110
+ const kindFor = (ts, file) => file.endsWith(".tsx") ? ts.ScriptKind.TSX : file.endsWith(".ts") ? ts.ScriptKind.TS : file.endsWith(".jsx") ? ts.ScriptKind.JSX : ts.ScriptKind.JS;
111
+ const parse = (ts, src, file) => ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true, kindFor(ts, file));
112
+ const hasMod = (ts, n, k) => (ts.canHaveModifiers(n) ? ts.getModifiers(n) : void 0)?.some((m) => m.kind === k) ?? false;
113
+ /** Removes `export` (and `default`) modifiers from a declaration. */
114
+ function stripExport(ts, n, sf) {
115
+ return ((ts.canHaveModifiers(n) ? ts.getModifiers(n) : void 0) ?? []).filter((m) => m.kind === ts.SyntaxKind.ExportKeyword || m.kind === ts.SyntaxKind.DefaultKeyword).map((m) => {
116
+ let end = m.getEnd();
117
+ while (/\s/.test(sf.text[end] ?? "")) end++;
118
+ return {
119
+ start: m.getStart(sf),
120
+ end,
121
+ text: ""
122
+ };
123
+ });
124
+ }
125
+ function exportedConst(ts, s, name) {
126
+ if (!ts.isVariableStatement(s) || !hasMod(ts, s, ts.SyntaxKind.ExportKeyword)) return null;
127
+ return s.declarationList.declarations.find((d) => ts.isIdentifier(d.name) && d.name.text === name) ?? null;
128
+ }
129
+ /** Wraps the page module: default export → withEvolve, metadata → evolveMetadata. */
130
+ function wrapPage(ts, src, file, path) {
131
+ const sf = parse(ts, src, file);
132
+ const first = sf.statements[0];
133
+ if (first && ts.isExpressionStatement(first) && ts.isStringLiteral(first.expression) && first.expression.text === "use client") return {
134
+ ok: false,
135
+ why: `the page is "use client" (withEvolve needs a server page)`
136
+ };
137
+ const edits = [];
138
+ let component = null;
139
+ let metadata = null;
140
+ const has = {
141
+ revalidate: false,
142
+ dynamicParams: false
143
+ };
144
+ for (const s of sf.statements) {
145
+ const dyn = exportedConst(ts, s, "dynamic");
146
+ if (dyn?.initializer && ts.isStringLiteral(dyn.initializer) && dyn.initializer.text === "force-dynamic") return {
147
+ ok: false,
148
+ why: `the page sets dynamic = "force-dynamic"`
149
+ };
150
+ const dp = exportedConst(ts, s, "dynamicParams");
151
+ if (dp) {
152
+ if (dp.initializer?.kind === ts.SyntaxKind.FalseKeyword) return {
153
+ ok: false,
154
+ why: "the page sets dynamicParams = false"
155
+ };
156
+ has.dynamicParams = true;
157
+ }
158
+ if (exportedConst(ts, s, "revalidate")) has.revalidate = true;
159
+ if (exportedConst(ts, s, "generateStaticParams")) return {
160
+ ok: false,
161
+ why: "the page has its own generateStaticParams"
162
+ };
163
+ if (ts.isFunctionDeclaration(s) && hasMod(ts, s, ts.SyntaxKind.ExportKeyword) && !hasMod(ts, s, ts.SyntaxKind.DefaultKeyword)) {
164
+ if (s.name?.text === "generateStaticParams") return {
165
+ ok: false,
166
+ why: "the page has its own generateStaticParams"
167
+ };
168
+ if (s.name?.text === "generateMetadata") {
169
+ edits.push(...stripExport(ts, s, sf));
170
+ edits.push({
171
+ start: s.name.getStart(sf),
172
+ end: s.name.getEnd(),
173
+ text: "baseMetadata"
174
+ });
175
+ metadata = "baseMetadata";
176
+ }
177
+ }
178
+ if (exportedConst(ts, s, "generateMetadata") && ts.isVariableStatement(s)) {
179
+ const decl = exportedConst(ts, s, "generateMetadata");
180
+ edits.push(...stripExport(ts, s, sf));
181
+ edits.push({
182
+ start: decl.name.getStart(sf),
183
+ end: decl.name.getEnd(),
184
+ text: "baseMetadata"
185
+ });
186
+ metadata = "baseMetadata";
187
+ }
188
+ if (exportedConst(ts, s, "metadata") && ts.isVariableStatement(s)) {
189
+ edits.push(...stripExport(ts, s, sf));
190
+ metadata = "metadata";
191
+ }
192
+ if ((ts.isFunctionDeclaration(s) || ts.isClassDeclaration(s)) && hasMod(ts, s, ts.SyntaxKind.DefaultKeyword)) {
193
+ edits.push(...stripExport(ts, s, sf));
194
+ if (s.name) component = s.name.text;
195
+ else {
196
+ component = "Page";
197
+ const kw = s.getChildren(sf).find((c) => c.kind === ts.SyntaxKind.FunctionKeyword || c.kind === ts.SyntaxKind.ClassKeyword);
198
+ const star = ts.isFunctionDeclaration(s) ? s.asteriskToken : void 0;
199
+ const at = star ? star.getEnd() : kw.getEnd();
200
+ let end = at;
201
+ while (/\s/.test(sf.text[end] ?? "")) end++;
202
+ edits.push({
203
+ start: at,
204
+ end,
205
+ text: " Page"
206
+ });
207
+ }
208
+ } else if (ts.isExportAssignment(s) && !s.isExportEquals) {
209
+ if (ts.isIdentifier(s.expression)) {
210
+ component = s.expression.text;
211
+ let end = s.getEnd();
212
+ if (sf.text[end] === "\n") end++;
213
+ edits.push({
214
+ start: s.getStart(sf),
215
+ end,
216
+ text: ""
217
+ });
218
+ } else {
219
+ component = "Page";
220
+ edits.push({
221
+ start: s.getStart(sf),
222
+ end: s.expression.getStart(sf),
223
+ text: "const Page = "
224
+ });
225
+ }
226
+ } else if (ts.isExportDeclaration(s) && s.exportClause && ts.isNamedExports(s.exportClause)) {
227
+ if (s.exportClause.elements.some((e) => e.name.text === "default")) return {
228
+ ok: false,
229
+ why: "the default export is re-exported by name; wrap it by hand"
230
+ };
231
+ }
232
+ }
233
+ if (!component) return {
234
+ ok: false,
235
+ why: "the page has no default export"
236
+ };
237
+ const want = [
238
+ "evolveMetadata",
239
+ "evolveStaticParams",
240
+ "withEvolve"
241
+ ];
242
+ const existing = sf.statements.find((s) => ts.isImportDeclaration(s) && ts.isStringLiteral(s.moduleSpecifier) && s.moduleSpecifier.text === "next-evolve" && !s.importClause?.isTypeOnly && !!s.importClause?.namedBindings && ts.isNamedImports(s.importClause.namedBindings));
243
+ let addLine = null;
244
+ if (existing) {
245
+ const named = existing.importClause.namedBindings;
246
+ const have = named.elements.map((e) => e.name.text);
247
+ const add = want.filter((n) => !have.includes(n));
248
+ if (add.length) {
249
+ const last = named.elements[named.elements.length - 1];
250
+ const at = last ? last.getEnd() : named.getStart(sf) + 1;
251
+ edits.push({
252
+ start: at,
253
+ end: at,
254
+ text: `${last ? ", " : " "}${add.join(", ")}`
255
+ });
256
+ }
257
+ } else addLine = `import { ${want.join(", ")} } from "next-evolve";`;
258
+ let code = applyEdits(src, edits);
259
+ if (addLine) code = addImport(code, addLine);
260
+ const p = JSON.stringify(path);
261
+ const tail = [
262
+ "",
263
+ "// Static mode (next-evolve): one cached render per variant under [evolve].",
264
+ ...has.dynamicParams ? [] : ["export const dynamicParams = true;"],
265
+ "export const generateStaticParams = evolveStaticParams;",
266
+ ...has.revalidate ? [] : ["export const revalidate = 3600;"],
267
+ `export const generateMetadata = evolveMetadata(${p}${metadata ? `, ${metadata}` : ""});`,
268
+ `export default withEvolve(${component}, { path: ${p} });`,
269
+ ""
270
+ ];
271
+ return {
272
+ ok: true,
273
+ code: `${code.replace(/\s*$/, "\n")}${tail.join("\n")}`
274
+ };
275
+ }
276
+ /** Rewrites relative specifiers for a file that moved; files moving together keep pointing at each other. */
277
+ function rewriteImports(ts, src, oldPath, newPath, moves) {
278
+ const sf = parse(ts, src, oldPath);
279
+ const edits = [];
280
+ const stripExt = (p) => p.replace(/\.(tsx?|jsx?|mjs|cjs)$/, "");
281
+ const movedNoExt = new Map([...moves].map(([o, n]) => [stripExt(o), stripExt(n)]));
282
+ const fix = (lit) => {
283
+ const spec = lit.text;
284
+ if (!spec.startsWith("./") && !spec.startsWith("../")) return;
285
+ let target = posix.normalize(posix.join(posix.dirname(oldPath), spec));
286
+ target = moves.get(target) ?? movedNoExt.get(target) ?? target;
287
+ let rel = posix.relative(posix.dirname(newPath), target);
288
+ if (!rel.startsWith(".")) rel = `./${rel}`;
289
+ if (rel !== spec) edits.push({
290
+ start: lit.getStart(sf) + 1,
291
+ end: lit.getEnd() - 1,
292
+ text: rel
293
+ });
294
+ };
295
+ const visit = (n) => {
296
+ if ((ts.isImportDeclaration(n) || ts.isExportDeclaration(n)) && n.moduleSpecifier && ts.isStringLiteral(n.moduleSpecifier)) fix(n.moduleSpecifier);
297
+ else if (ts.isCallExpression(n) && (n.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(n.expression) && n.expression.text === "require") && n.arguments[0] && ts.isStringLiteralLike(n.arguments[0])) fix(n.arguments[0]);
298
+ else if (ts.isImportTypeNode(n) && ts.isLiteralTypeNode(n.argument) && ts.isStringLiteral(n.argument.literal)) fix(n.argument.literal);
299
+ ts.forEachChild(n, visit);
300
+ };
301
+ visit(sf);
302
+ return applyEdits(src, edits);
303
+ }
304
+ export { loadTypescript, planPages, rewriteImports, wrapPage };
@@ -0,0 +1,27 @@
1
+ export type FileChange = {
2
+ path: string;
3
+ before: string | null;
4
+ after: string | null;
5
+ };
6
+ export declare class Plan {
7
+ readonly root: string;
8
+ private readonly files;
9
+ /** Things the developer must do by hand (printed at the end). */
10
+ readonly manual: string[];
11
+ readonly notes: string[];
12
+ constructor(root: string);
13
+ private disk;
14
+ /** Current content, including planned changes. */
15
+ read(path: string): string | null;
16
+ exists(path: string): boolean;
17
+ write(path: string, content: string): void;
18
+ /** Writes only if the file doesn't exist yet. Returns whether it will be created. */
19
+ create(path: string, content: string): boolean;
20
+ remove(path: string): void;
21
+ move(from: string, to: string): void;
22
+ /** Changes that differ from disk, sorted by path. */
23
+ changes(): FileChange[];
24
+ apply(): void;
25
+ }
26
+ /** A readable diff of the plan: whole new files, line diffs for changed ones. */
27
+ export declare function renderDiff(changes: FileChange[]): string;
@@ -0,0 +1,129 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ // src/cli/plan.ts
4
+ var Plan = class {
5
+ root;
6
+ files = /* @__PURE__ */ new Map();
7
+ /** Things the developer must do by hand (printed at the end). */
8
+ manual = [];
9
+ notes = [];
10
+ constructor(root) {
11
+ this.root = root;
12
+ }
13
+ disk(path) {
14
+ const p = join(this.root, path);
15
+ try {
16
+ return existsSync(p) ? readFileSync(p, "utf8") : null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+ /** Current content, including planned changes. */
22
+ read(path) {
23
+ return this.files.has(path) ? this.files.get(path) : this.disk(path);
24
+ }
25
+ exists(path) {
26
+ return this.read(path) !== null;
27
+ }
28
+ write(path, content) {
29
+ this.files.set(path, content);
30
+ }
31
+ /** Writes only if the file doesn't exist yet. Returns whether it will be created. */
32
+ create(path, content) {
33
+ if (this.exists(path)) return false;
34
+ this.write(path, content);
35
+ return true;
36
+ }
37
+ remove(path) {
38
+ this.files.set(path, null);
39
+ }
40
+ move(from, to) {
41
+ const c = this.read(from);
42
+ if (c === null) return;
43
+ this.write(to, c);
44
+ this.remove(from);
45
+ }
46
+ /** Changes that differ from disk, sorted by path. */
47
+ changes() {
48
+ const out = [];
49
+ for (const [path, after] of this.files) {
50
+ const before = this.disk(path);
51
+ if (before !== after) out.push({
52
+ path,
53
+ before,
54
+ after
55
+ });
56
+ }
57
+ return out.sort((a, b) => a.path.localeCompare(b.path));
58
+ }
59
+ apply() {
60
+ for (const c of this.changes()) {
61
+ const p = join(this.root, c.path);
62
+ if (c.after === null) rmSync(p, { force: true });
63
+ else {
64
+ mkdirSync(dirname(p), { recursive: true });
65
+ writeFileSync(p, c.after);
66
+ }
67
+ }
68
+ }
69
+ };
70
+ /** A readable diff of the plan: whole new files, line diffs for changed ones. */
71
+ function renderDiff(changes) {
72
+ const out = [];
73
+ for (const raw of changes) {
74
+ const c = /(^|\/)\.env/.test(raw.path) ? maskEnv(raw) : raw;
75
+ if (c.before === null) {
76
+ out.push(`+++ ${c.path} (new)`);
77
+ for (const l of c.after.replace(/\n$/, "").split("\n")) out.push(`+ ${l}`);
78
+ } else if (c.after === null) out.push(`--- ${c.path} (removed)`);
79
+ else {
80
+ out.push(`*** ${c.path}`);
81
+ out.push(...lineDiff(c.before, c.after));
82
+ }
83
+ out.push("");
84
+ }
85
+ return out.join("\n");
86
+ }
87
+ /** Line diff via LCS, with 1 line of context around changes. */
88
+ function lineDiff(a, b) {
89
+ const x = a.replace(/\n$/, "").split("\n");
90
+ const y = b.replace(/\n$/, "").split("\n");
91
+ const n = x.length;
92
+ const m = y.length;
93
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
94
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) lcs[i][j] = x[i] === y[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
95
+ const ops = [];
96
+ let i = 0;
97
+ let j = 0;
98
+ while (i < n && j < m) if (x[i] === y[j]) {
99
+ ops.push([" ", x[i]]);
100
+ i++;
101
+ j++;
102
+ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) ops.push(["-", x[i++]]);
103
+ else ops.push(["+", y[j++]]);
104
+ while (i < n) ops.push(["-", x[i++]]);
105
+ while (j < m) ops.push(["+", y[j++]]);
106
+ const changed = (k) => ops[k] !== void 0 && ops[k][0] !== " ";
107
+ const lines = [];
108
+ let skipped = false;
109
+ ops.forEach((o, k) => {
110
+ if (changed(k) || changed(k - 1) || changed(k + 1)) {
111
+ lines.push(`${o[0]} ${o[1]}`);
112
+ skipped = false;
113
+ } else if (!skipped) {
114
+ lines.push(" …");
115
+ skipped = true;
116
+ }
117
+ });
118
+ return lines;
119
+ }
120
+ /** Never print secrets: `KEY=value` → `KEY=…` in env files. */
121
+ function maskEnv(c) {
122
+ const mask = (s) => s?.replace(/^(\s*[A-Z0-9_]+\s*=).+$/gm, "$1…") ?? null;
123
+ return {
124
+ ...c,
125
+ before: mask(c.before),
126
+ after: mask(c.after)
127
+ };
128
+ }
129
+ export { Plan, renderDiff };
@@ -0,0 +1,13 @@
1
+ type Env = Record<string, string | undefined>;
2
+ export declare const BUILD_BUNDLE_PATH = ".evolve/bundle.generated.ts";
3
+ export type PullDeps = {
4
+ env: Env;
5
+ cwd?: string;
6
+ out?: (s: string) => void;
7
+ err?: (s: string) => void;
8
+ };
9
+ export declare const renderBuildBundle: (token: string | null) => string;
10
+ /** The token inside a generated module, or null. */
11
+ export declare function readBuildBundle(source: string): string | null;
12
+ export declare function pullCommand(_args: string[], deps: PullDeps): Promise<number>;
13
+ export {};
@@ -0,0 +1,62 @@
1
+ import { trustedKeys } from "../core/keys.js";
2
+ import { fetchBundle } from "../store/fetch.js";
3
+ import "../store/runtime.js";
4
+ import { parseSecretKey, verifyBundle } from "@next-evolve/protocol";
5
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ // src/cli/pull.ts
8
+ const BUILD_BUNDLE_PATH = ".evolve/bundle.generated.ts";
9
+ const HEADER = "// Generated by next-evolve pull. Do not edit; do not commit.\n";
10
+ const renderBuildBundle = (token) => `${HEADER}const bundle: string | null = ${JSON.stringify(token)};\nexport default bundle;\n`;
11
+ /** The token inside a generated module, or null. */
12
+ function readBuildBundle(source) {
13
+ const m = /const bundle: string \| null = ("[A-Za-z0-9_.-]+");/.exec(source);
14
+ return m ? JSON.parse(m[1]) : null;
15
+ }
16
+ async function pullCommand(_args, deps) {
17
+ const { env } = deps;
18
+ const out = deps.out ?? ((s) => console.warn(s));
19
+ const err = deps.err ?? ((s) => console.error(s));
20
+ const file = join(deps.cwd ?? process.cwd(), BUILD_BUNDLE_PATH);
21
+ mkdirSync(dirname(file), { recursive: true });
22
+ const key = env.EVOLVE_SECRET_KEY;
23
+ const site = parseSecretKey(key ?? "")?.siteId;
24
+ const keys = trustedKeys(env);
25
+ const now = Math.floor(Date.now() / 1e3);
26
+ const valid = (token) => token && site ? verifyBundle(token, {
27
+ keys,
28
+ site,
29
+ now
30
+ }) : null;
31
+ let current = null;
32
+ try {
33
+ current = readFileSync(file, "utf8");
34
+ } catch {}
35
+ const previous = current === null ? null : readBuildBundle(current);
36
+ const write = (token) => {
37
+ const next = renderBuildBundle(token);
38
+ if (next !== current) writeFileSync(file, next);
39
+ };
40
+ const keep = (why) => {
41
+ const token = valid(previous)?.ok ? previous : null;
42
+ write(token);
43
+ err(`next-evolve pull: ${why}; ${token ? "kept the previous bundle" : "wrote an empty build bundle (control until the first fetch)"}`);
44
+ return 0;
45
+ };
46
+ if (!key || !site) return keep("EVOLVE_SECRET_KEY is not set");
47
+ const cloudUrl = env.EVOLVE_CLOUD_URL ?? "https://api.evolveab.com";
48
+ if (!cloudUrl) return keep("no cloud URL");
49
+ const res = await fetchBundle({
50
+ cloudUrl,
51
+ secretKey: key,
52
+ timeoutMs: 1e4
53
+ });
54
+ if (res.status === "error") return keep(`bundle fetch failed (${res.code})`);
55
+ if (res.status !== "ok") return keep("unexpected 304");
56
+ const v = valid(res.token);
57
+ if (!v?.ok) return keep(`bundle rejected (${v?.reason ?? "invalid"})`);
58
+ write(res.token);
59
+ out(`next-evolve pull: wrote ${BUILD_BUNDLE_PATH} (ver ${v.payload.ver})`);
60
+ return 0;
61
+ }
62
+ export { BUILD_BUNDLE_PATH, pullCommand, readBuildBundle, renderBuildBundle };
@@ -0,0 +1,14 @@
1
+ import { AppModules } from "./load-app.js";
2
+ type Env = Record<string, string | undefined>;
3
+ export type PushDeps = {
4
+ env: Env;
5
+ cwd?: string;
6
+ out?: (s: string) => void;
7
+ err?: (s: string) => void;
8
+ /** No success output (init prints its own). */
9
+ quiet?: boolean;
10
+ /** Inject the app's modules (tests). */
11
+ load?: () => Promise<AppModules>;
12
+ };
13
+ export declare function pushCommand(_args: string[], deps: PushDeps): Promise<number>;
14
+ export {};
@@ -0,0 +1,33 @@
1
+ import { buildManifest, detectAppUrl } from "../cloud/manifest.js";
2
+ import { pushToCloud } from "../cloud/push.js";
3
+ import { loadAppModules, reexecWithTypes } from "./load-app.js";
4
+ // src/cli/push.ts
5
+ async function pushCommand(_args, deps) {
6
+ const out = deps.out ?? ((s) => console.warn(s));
7
+ const err = deps.err ?? ((s) => console.error(s));
8
+ const cwd = deps.cwd ?? process.cwd();
9
+ if (!deps.env.EVOLVE_SECRET_KEY) {
10
+ err("next-evolve push: set EVOLVE_SECRET_KEY");
11
+ return 1;
12
+ }
13
+ let mods;
14
+ if (deps.load) mods = await deps.load();
15
+ else {
16
+ const r = await loadAppModules(cwd);
17
+ if (!r.ok) {
18
+ if (r.needsTs && process.argv[1] && !deps.load) return reexecWithTypes("push", [], cwd, deps.env, deps.quiet);
19
+ err(`next-evolve push: ${r.error}. The manifest is also sent when your app starts.`);
20
+ return 1;
21
+ }
22
+ mods = r.mods;
23
+ }
24
+ const manifest = buildManifest(mods.config, mods.goals, { appUrl: detectAppUrl(deps.env) });
25
+ const res = await pushToCloud("/v1/manifest", manifest, { env: deps.env });
26
+ if (!res.ok) {
27
+ err(`next-evolve push: the cloud refused the manifest (${res.status})`);
28
+ return 1;
29
+ }
30
+ out(`next-evolve push: sent ${Object.keys(manifest.routes).length} route(s) and ${Object.keys(manifest.goals).length} goal(s)`);
31
+ return 0;
32
+ }
33
+ export { pushCommand };
@@ -1,6 +1,8 @@
1
1
  import { EvolveConfig } from "../config/config.js";
2
2
  import { Goals } from "../config/goals.js";
3
3
  import { Manifest } from "@next-evolve/protocol";
4
+ /** This package's version (`0.0.0` if unknown). */
5
+ export declare function sdkVersion(): string;
4
6
  /** The manifest this app sends to the cloud (spec §8.4). Configuration only. */
5
7
  export declare function buildManifest(config: EvolveConfig, goals: Goals | undefined, opts?: {
6
8
  appUrl?: string | null;
@@ -11,6 +11,10 @@ const versionOf = (load) => {
11
11
  return null;
12
12
  }
13
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
+ }
14
18
  /** The manifest this app sends to the cloud (spec §8.4). Configuration only. */
15
19
  function buildManifest(config, goals, opts = {}) {
16
20
  const routes = {};
@@ -18,7 +22,7 @@ function buildManifest(config, goals, opts = {}) {
18
22
  const serialized = goals ? serializeGoals(goals) : {};
19
23
  return {
20
24
  pv: PROTOCOL_VERSION,
21
- sdkVersion: versionOf(() => require("next-evolve/package.json")) ?? "0.0.0",
25
+ sdkVersion: sdkVersion(),
22
26
  protocol: { supported: [...SUPPORTED_PROTOCOLS] },
23
27
  nextVersion: versionOf(() => require("next/package.json")),
24
28
  routes,
@@ -39,4 +43,4 @@ function detectAppUrl(env = process.env) {
39
43
  if (env.VERCEL_ENV === "production" && env.VERCEL_PROJECT_PRODUCTION_URL) return `https://${env.VERCEL_PROJECT_PRODUCTION_URL}`;
40
44
  return null;
41
45
  }
42
- export { buildManifest, detectAppUrl };
46
+ export { buildManifest, detectAppUrl, sdkVersion };
@@ -5,6 +5,18 @@ type Variant = {
5
5
  };
6
6
  /** Picks a variant id for r ∈ [0, 1) proportionally to weight (w > 0 only). */
7
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;
8
20
  export type Assignment = {
9
21
  /** Pairs for the variant code (unsorted; `encodeCode` sorts). */
10
22
  pairs: CodePair[];
@@ -19,5 +31,5 @@ export type Assignment = {
19
31
  * winner and aren't counted. Cookie entries for experiments no longer in the
20
32
  * bundle are pruned.
21
33
  */
22
- export declare function assign(bundle: BundlePayload, path: string, cookie: ReadonlyMap<string, string>, rand: () => number): Assignment;
34
+ export declare function assign(bundle: BundlePayload, path: string, cookie: ReadonlyMap<string, string>, rand: Draw): Assignment;
23
35
  export {};