@uniformdev/next-app-router-upgrade 0.0.0-stage → 0.2.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.
@@ -0,0 +1,38 @@
1
+ // Phase 2 on Vercel: turn on edge mode with vercelUniformEdgeMiddleware.
2
+ // The steps follow "Set up edge mode on Vercel" in the docs.
3
+
4
+ import { migrateEdgeMiddleware, migrateRouteFile as migrateRoutes, planEdge, SDK } from "./common.js";
5
+
6
+ const VERCEL = `${SDK}/vercel`;
7
+ const VERCEL_FUNCTIONS_RANGE = "^3.7.2";
8
+
9
+ const HOST = {
10
+ factory: "vercelUniformEdgeMiddleware",
11
+ module: VERCEL,
12
+ runtime: true,
13
+ onRevalidateTags: {
14
+ name: "expireVercelRuntimeCacheTags",
15
+ module: VERCEL,
16
+ description: "Expire the Vercel runtime cache when content is published: onRevalidateTags: expireVercelRuntimeCacheTags.",
17
+ },
18
+ packages: [{ name: "@vercel/functions", range: VERCEL_FUNCTIONS_RANGE, description: "vercelUniformEdgeMiddleware needs it." }],
19
+ reminders: [
20
+ "If the deployment uses Vercel Deployment Protection, turn on Protection Bypass for Automation in the project settings. Then the edge middleware can fetch the page that it processes.",
21
+ ],
22
+ };
23
+
24
+ /**
25
+ * Returns { changes, contents, manual, reminders } for phase 2 on Vercel. Reminders are not code changes.
26
+ * `base` maps absolute paths to the text after phase 1, so that the two phases make one plan.
27
+ */
28
+ export function planVercelEdge(root, { base } = {}) {
29
+ return planEdge(root, { base, host: HOST });
30
+ }
31
+
32
+ export function migrateMiddleware(root, file, text) {
33
+ return migrateEdgeMiddleware(root, file, text, HOST);
34
+ }
35
+
36
+ export function migrateRouteFile(root, file, text) {
37
+ return migrateRoutes(root, file, text, { onRevalidateTags: HOST.onRevalidateTags });
38
+ }
@@ -0,0 +1,111 @@
1
+ // The interactive mode: a guide through the check, phase 1 (the SDK upgrade) and phase 2 (edge mode).
2
+
3
+ import { spawnSync } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { createInterface } from "node:readline/promises";
7
+
8
+ import { PROVIDERS } from "./edge/index.js";
9
+ import { formatText, TARGET_VERSION, verdict } from "./report.js";
10
+ import { colors, executeMigration, NAME, projectStatus } from "./session.js";
11
+
12
+ export async function runInteractive({ root, color, uniformData }) {
13
+ const c = colors(color);
14
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
15
+ const ask = async (question, defaultYes = false) => {
16
+ const answer = (await rl.question(`${c("36", "?")} ${question} ${c("2", defaultYes ? "[Y/n]" : "[y/N]")} `)).trim();
17
+ return answer ? /^y(es)?$/i.test(answer) : defaultYes;
18
+ };
19
+ const choose = async (question, options) => {
20
+ console.log(`${c("36", "?")} ${c("1", question)}`);
21
+ options.forEach((option, i) => {
22
+ const label = option.disabled ? c("2", `${option.label} (${option.hint})`) : `${option.label}${option.hint ? c("2", ` ${option.hint}`) : ""}`;
23
+ console.log(` ${c("36", String(i + 1))}) ${label}`);
24
+ });
25
+ for (;;) {
26
+ const answer = Number((await rl.question(` Select 1-${options.length}: `)).trim());
27
+ const option = options[answer - 1];
28
+ if (option) {
29
+ console.log("");
30
+ return option;
31
+ }
32
+ }
33
+ };
34
+
35
+ try {
36
+ for (;;) {
37
+ const status = projectStatus(root, { uniformData });
38
+ printHeader(root, status, c);
39
+
40
+ const phase1Hint = status.phase1Pending ? `${status.phase1Pending} automatic change(s) to do` : "done";
41
+ const phase2Hint = status.edgeMode ? `edge mode is on (${status.edgeMode})` : "lite mode now";
42
+ const action = await choose("What do you want to do?", [
43
+ { value: "check", label: "Check compatibility", hint: "a full report, no changes" },
44
+ { value: "phase1", label: "Phase 1: upgrade the SDK", hint: phase1Hint },
45
+ { value: "phase2", label: "Phase 2: turn on edge mode", hint: phase2Hint },
46
+ { value: "exit", label: "Exit" },
47
+ ]);
48
+
49
+ if (action.value === "exit") return 0;
50
+
51
+ if (action.value === "check") {
52
+ console.log(formatText(status.scan, verdict(status.scan.findings), { color }));
53
+ console.log("");
54
+ await rl.question(c("2", "Press Enter to go back to the menu. "));
55
+ continue;
56
+ }
57
+
58
+ let edge;
59
+ if (action.value === "phase2") {
60
+ const host = await choose("Which host serves your app?", [
61
+ ...Object.entries(PROVIDERS).map(([key, provider]) => ({
62
+ value: key,
63
+ label: provider.name,
64
+ hint: provider.available ? provider.summary : "coming soon",
65
+ disabled: !provider.available,
66
+ })),
67
+ { value: "back", label: "Back" },
68
+ ]);
69
+ if (host.value === "back") continue;
70
+ if (host.disabled) {
71
+ console.log(`${PROVIDERS[host.value].name} is not in this CLI yet. To set up edge mode by hand, follow the docs:`);
72
+ console.log(c("36", ` ${PROVIDERS[host.value].docs}`));
73
+ console.log("");
74
+ continue;
75
+ }
76
+ edge = host.value;
77
+ if (status.phase1Pending) {
78
+ console.log(c("33", `Phase 1 is not done. The plan includes its ${status.phase1Pending} change(s) too.`));
79
+ console.log("");
80
+ }
81
+ }
82
+
83
+ const code = await executeMigration({ root, edge, color, uniformData, ask, interactive: true, diff: "ask" });
84
+ if (code !== 2 && fs.existsSync(path.join(root, "package.json"))) await offerInstall(root, ask, c);
85
+ await rl.question(c("2", "Press Enter to go back to the menu. "));
86
+ }
87
+ } finally {
88
+ rl.close();
89
+ }
90
+ }
91
+
92
+ function printHeader(root, status, c) {
93
+ const { scan } = status;
94
+ const result = verdict(scan.findings);
95
+ console.log("");
96
+ console.log(c("1", "Uniform Next.js App Router SDK upgrade"));
97
+ console.log(` Project ${scan.project.name ?? path.basename(root)} ${c("2", `(${root})`)}`);
98
+ console.log(` SDK ${status.sdkVersion}${status.atTarget ? c("32", " (target version)") : c("2", ` → ${TARGET_VERSION}`)}`);
99
+ console.log(` Check ${c(result.safe ? "32" : "31", result.label)} ${c("2", `(${result.errors} error(s), ${result.warnings} warning(s))`)}`);
100
+ console.log(` Mode ${status.edgeMode ? c("32", `edge mode (${status.edgeMode})`) : "lite mode: the browser chooses the variants after hydration"}`);
101
+ console.log("");
102
+ }
103
+
104
+ async function offerInstall(root, ask, c) {
105
+ const manager = fs.existsSync(path.join(root, "pnpm-lock.yaml")) ? "pnpm" : fs.existsSync(path.join(root, "yarn.lock")) ? "yarn" : "npm";
106
+ if (!(await ask(`Run "${manager} install" now?`, false))) return;
107
+ const result = spawnSync(manager, ["install"], { cwd: root, stdio: "inherit", shell: process.platform === "win32" });
108
+ console.log(result.status === 0 ? c("32", "Install done.") : c("31", `${manager} install failed. Run it yourself, and read the error.`));
109
+ console.log(c("2", `Then run: ${NAME} check`));
110
+ console.log("");
111
+ }
@@ -0,0 +1,95 @@
1
+ // Small edits to JSON text that keep the formatting of all other lines.
2
+
3
+ /** Returns [start, end] of the object value of a top-level key, from "{" to "}" inclusive. */
4
+ export function findTopLevelObject(text, key) {
5
+ let depth = 0;
6
+ for (let i = 0; i < text.length; i++) {
7
+ const ch = text[i];
8
+ if (ch === '"') {
9
+ const end = endOfString(text, i);
10
+ if (depth === 1) {
11
+ const name = JSON.parse(text.slice(i, end + 1));
12
+ const rest = /^\s*:\s*/.exec(text.slice(end + 1));
13
+ if (name === key && rest && text[end + 1 + rest[0].length] === "{") {
14
+ const start = end + 1 + rest[0].length;
15
+ return [start, matchBrace(text, start)];
16
+ }
17
+ }
18
+ i = end;
19
+ } else if (ch === "{" || ch === "[") depth++;
20
+ else if (ch === "}" || ch === "]") depth--;
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ function endOfString(text, start) {
26
+ for (let i = start + 1; i < text.length; i++) {
27
+ if (text[i] === "\\") i++;
28
+ else if (text[i] === '"') return i;
29
+ }
30
+ throw new Error("Unterminated string in JSON");
31
+ }
32
+
33
+ function matchBrace(text, start) {
34
+ let depth = 0;
35
+ for (let i = start; i < text.length; i++) {
36
+ if (text[i] === '"') i = endOfString(text, i);
37
+ else if (text[i] === "{" || text[i] === "[") depth++;
38
+ else if (text[i] === "}" || text[i] === "]") {
39
+ depth--;
40
+ if (depth === 0) return i;
41
+ }
42
+ }
43
+ throw new Error("Unbalanced JSON");
44
+ }
45
+
46
+ const quote = (s) => JSON.stringify(s);
47
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48
+
49
+ /** Changes the value of `name` inside the top-level object `block`. Returns the new text, or undefined. */
50
+ export function setValueInBlock(text, block, name, value) {
51
+ const range = findTopLevelObject(text, block);
52
+ if (!range) return undefined;
53
+ const inner = text.slice(range[0], range[1] + 1);
54
+ const re = new RegExp(`(${escapeRe(quote(name))}\\s*:\\s*)"(?:[^"\\\\]|\\\\.)*"`);
55
+ if (!re.test(inner)) return undefined;
56
+ return text.slice(0, range[0]) + inner.replace(re, `$1${quote(value)}`) + text.slice(range[1] + 1);
57
+ }
58
+
59
+ /**
60
+ * Adds `name: value` to the top-level object `block`, in alphabetical order when the keys are in
61
+ * alphabetical order. Adds the block when it does not exist.
62
+ */
63
+ export function addToBlock(text, block, name, value) {
64
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
65
+ const unit = /^([ \t]+)"/m.exec(text)?.[1] ?? " ";
66
+ const range = findTopLevelObject(text, block);
67
+
68
+ if (!range) {
69
+ // Add the block before the last "}" of the file.
70
+ const close = text.lastIndexOf("}");
71
+ const before = text.slice(0, close).replace(/\s*$/, "");
72
+ const entry = `${unit}${quote(block)}: {${eol}${unit}${unit}${quote(name)}: ${quote(value)}${eol}${unit}}`;
73
+ return `${before},${eol}${entry}${eol}${text.slice(close)}`;
74
+ }
75
+
76
+ const [start, end] = range;
77
+ const body = text.slice(start + 1, end);
78
+ const keys = [...body.matchAll(/"((?:[^"\\]|\\.)*)"\s*:/g)].map((m) => ({ name: JSON.parse(`"${m[1]}"`), index: start + 1 + m.index }));
79
+ if (!keys.length) return text.slice(0, start) + `{ ${quote(name)}: ${quote(value)} }` + text.slice(end + 1);
80
+
81
+ const indent = /([ \t]*)$/.exec(text.slice(0, keys[0].index))[1];
82
+ const multiline = body.includes("\n");
83
+ const sorted = keys.every((key, i) => i === 0 || keys[i - 1].name.localeCompare(key.name) <= 0);
84
+ const next = sorted ? keys.find((key) => key.name.localeCompare(name) > 0) : undefined;
85
+
86
+ if (next) {
87
+ const entry = multiline ? `${quote(name)}: ${quote(value)},${eol}${indent}` : `${quote(name)}: ${quote(value)}, `;
88
+ return text.slice(0, next.index) + entry + text.slice(next.index);
89
+ }
90
+ // Add after the last value.
91
+ const lastValueEnd = start + 1 + body.replace(/\s*$/, "").length;
92
+ const hasComma = text[lastValueEnd - 1] === ",";
93
+ const entry = multiline ? `${hasComma ? "" : ","}${eol}${indent}${quote(name)}: ${quote(value)}` : `${hasComma ? "" : ","} ${quote(name)}: ${quote(value)}`;
94
+ return text.slice(0, lastValueEnd) + entry + text.slice(lastValueEnd);
95
+ }
package/src/migrate.js ADDED
@@ -0,0 +1,386 @@
1
+ // Plans the code changes for the upgrade to the developer preview. The plan contains only changes
2
+ // that keep the behavior of the app: each removed option either had no effect, or set the default.
3
+ // All other findings stay for the developer, with a file and a line.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import ts from "typescript";
8
+
9
+ import { COMPOSITION_METADATA, findClientContextIssues } from "./client-context.js";
10
+ import { TARGET_VERSION } from "./report.js";
11
+ import { addToBlock, setValueInBlock } from "./json-text.js";
12
+ import { REMOVED_EXPORTS } from "./rules.js";
13
+ import { listSourceFiles, scriptKind } from "./scan.js";
14
+
15
+ const SDK = "@uniformdev/next-app-router";
16
+ // These packages have their own version line. The registry check also finds such packages.
17
+ const SEPARATE_VERSION_LINE = new Set(["@uniformdev/toolbar-react", "@uniformdev/context-edge"]);
18
+ // The stable SDK (20.82 to 20.85) installs this version of @vercel/functions as a dependency.
19
+ const VERCEL_FUNCTIONS_RANGE = "^3.7.2";
20
+ const DEFAULT_DATA_CLIENT = "DefaultDataClient";
21
+
22
+ /**
23
+ * Returns { changes, contents, manual } for the project at `root`.
24
+ * `changes` lists each change with its file, line and description.
25
+ * `contents` maps each changed absolute path to its new text. Nothing is written.
26
+ * `manual` lists package.json steps that the migration could not do.
27
+ * `published` maps @uniformdev package names to true, false or undefined (refer to registry.js).
28
+ * Without it, the plan assumes that each package exists at the target version.
29
+ */
30
+ export function planMigration(root, { published } = {}) {
31
+ const changes = [];
32
+ const contents = new Map();
33
+
34
+ const sourceFiles = listSourceFiles(root);
35
+ const importsVercelFunctions = [];
36
+ for (const file of sourceFiles) {
37
+ const text = fs.readFileSync(file, "utf8");
38
+ const result = migrateSourceFile(root, file, text);
39
+ if (result.importsVercelFunctions) importsVercelFunctions.push(file);
40
+ if (result.text !== text) {
41
+ contents.set(file, result.text);
42
+ changes.push(...result.changes);
43
+ }
44
+ }
45
+
46
+ const pkgFile = path.join(root, "package.json");
47
+ const pkgText = fs.readFileSync(pkgFile, "utf8");
48
+ const pkgResult = migratePackageJson(pkgText, {
49
+ importsVercelFunctions: importsVercelFunctions.length > 0,
50
+ packageManager: detectPackageManager(root),
51
+ published,
52
+ });
53
+ if (pkgResult.text !== pkgText) {
54
+ contents.set(pkgFile, pkgResult.text);
55
+ }
56
+ changes.push(...pkgResult.changes.map((change) => ({ file: "package.json", ...change })));
57
+
58
+ return { changes, contents, manual: pkgResult.manual };
59
+ }
60
+
61
+ /** The @uniformdev dependencies that the migration sets to the target version. */
62
+ export function uniformDependencies(pkg) {
63
+ const names = new Set();
64
+ for (const field of ["dependencies", "devDependencies"]) {
65
+ for (const [name, version] of Object.entries(pkg[field] ?? {})) {
66
+ if (name.startsWith("@uniformdev/") && !SEPARATE_VERSION_LINE.has(name) && !/^(file|link|workspace|git|github|https?):/.test(version)) names.add(name);
67
+ }
68
+ }
69
+ return [...names];
70
+ }
71
+
72
+ function detectPackageManager(root) {
73
+ if (fs.existsSync(path.join(root, "pnpm-lock.yaml"))) return "pnpm";
74
+ if (fs.existsSync(path.join(root, "yarn.lock"))) return "yarn";
75
+ return "npm";
76
+ }
77
+
78
+ // package.json
79
+
80
+ export function migratePackageJson(text, { importsVercelFunctions, packageManager = "npm", published }) {
81
+ const pkg = JSON.parse(text);
82
+ const changes = [];
83
+ const manual = [];
84
+ let out = text;
85
+
86
+ for (const name of uniformDependencies(pkg)) {
87
+ const isPublished = published ? published.get(name) : true;
88
+ if (isPublished === false) continue; // This package has its own version line.
89
+ if (isPublished === undefined) {
90
+ manual.push(`Could not ask the npm registry if ${name}@${TARGET_VERSION} exists. Set its version yourself.`);
91
+ continue;
92
+ }
93
+ for (const field of ["dependencies", "devDependencies"]) {
94
+ const version = pkg[field]?.[name];
95
+ if (!version || version === TARGET_VERSION) continue;
96
+ out = setValueInBlock(out, field, name, TARGET_VERSION) ?? out;
97
+ changes.push({ rule: "version", description: `Set ${name} from ${version} to ${TARGET_VERSION}.` });
98
+ }
99
+ }
100
+
101
+ const listed = pkg.dependencies?.["@vercel/functions"] || pkg.devDependencies?.["@vercel/functions"];
102
+ if (importsVercelFunctions && !listed) {
103
+ out = addToBlock(out, "dependencies", "@vercel/functions", VERCEL_FUNCTIONS_RANGE);
104
+ changes.push({
105
+ rule: "vercel-functions-transitive",
106
+ description: `Add @vercel/functions ${VERCEL_FUNCTIONS_RANGE} to dependencies. The stable SDK installs it for you.`,
107
+ });
108
+ }
109
+
110
+ // A prerelease does not match the ^20.x peer range of @uniformdev/toolbar-react. Without an
111
+ // override, npm fails with ERESOLVE, and the app can get two copies of @uniformdev/context.
112
+ const usesToolbar = pkg.dependencies?.["@uniformdev/toolbar-react"] || pkg.devDependencies?.["@uniformdev/toolbar-react"];
113
+ if (TARGET_VERSION.includes("-") && usesToolbar) {
114
+ const field = { npm: "overrides", yarn: "resolutions" }[packageManager];
115
+ if (!field) {
116
+ manual.push(`Add "@uniformdev/context": "${TARGET_VERSION}" to the pnpm overrides, so that @uniformdev/toolbar-react accepts the prerelease version.`);
117
+ } else if (!pkg[field]?.["@uniformdev/context"]) {
118
+ out = addToBlock(out, field, "@uniformdev/context", TARGET_VERSION);
119
+ changes.push({
120
+ rule: "prerelease-peer-range",
121
+ description: `Add "@uniformdev/context": "${TARGET_VERSION}" to ${field}. @uniformdev/toolbar-react does not accept a prerelease version without it.`,
122
+ });
123
+ }
124
+ }
125
+
126
+ return { text: out, changes, manual };
127
+ }
128
+
129
+ // Source files
130
+
131
+ export function migrateSourceFile(root, file, text) {
132
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(file));
133
+ const rel = path.relative(root, file);
134
+ const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
135
+ const changes = [];
136
+ const removals = []; // { list, element } items, grouped by list later.
137
+ const record = (node, rule, description) => changes.push({ file: rel, line: lineOf(node), rule, description });
138
+
139
+ const sdkLocals = new Map();
140
+ let importsVercelFunctions = false;
141
+ for (const statement of sf.statements) {
142
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
143
+ const module = statement.moduleSpecifier.text;
144
+ if (module === "@vercel/functions" || module.startsWith("@vercel/functions/")) importsVercelFunctions = true;
145
+ if (!module.startsWith(SDK)) continue;
146
+ const bindings = statement.importClause?.namedBindings;
147
+ if (bindings && ts.isNamedImports(bindings)) {
148
+ for (const element of bindings.elements) {
149
+ sdkLocals.set(element.name.text, { module, imported: (element.propertyName ?? element.name).text, element, statement });
150
+ }
151
+ }
152
+ }
153
+
154
+ const isConfigFile = /^uniform\.server\.config\.[cm]?[jt]s$/.test(path.basename(file));
155
+ if (!sdkLocals.size && !isConfigFile) return { text, changes, importsVercelFunctions };
156
+
157
+ const isSdkCallee = (expression) => ts.isIdentifier(expression) && sdkLocals.has(expression.text);
158
+ const isDefaultDataClient = (expression) =>
159
+ expression &&
160
+ ts.isNewExpression(expression) &&
161
+ ts.isIdentifier(expression.expression) &&
162
+ sdkLocals.get(expression.expression.text)?.imported === DEFAULT_DATA_CLIENT &&
163
+ !(expression.arguments ?? []).length;
164
+
165
+ const visit = (node) => {
166
+ // Options of SDK functions: uniformMiddleware({ ... }), resolveRouteFromCode({ ... }) and others.
167
+ if (ts.isCallExpression(node) && isSdkCallee(node.expression)) {
168
+ for (const argument of node.arguments) {
169
+ if (!ts.isObjectLiteralExpression(argument)) continue;
170
+ for (const property of argument.properties) {
171
+ const name = property.name && propertyName(property.name);
172
+ if (name === "pathPatternsWithVariations") {
173
+ removals.push({ list: argument.properties, element: property, owner: argument });
174
+ record(property, "removed-option", 'Remove the "pathPatternsWithVariations" option. The middleware no longer evaluates variants.');
175
+ }
176
+ if (name === "dataClient" && ts.isPropertyAssignment(property) && isDefaultDataClient(property.initializer)) {
177
+ removals.push({ list: argument.properties, element: property, owner: argument });
178
+ record(property, "removed-option", 'Remove "dataClient: new DefaultDataClient()". It set the default data client.');
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ // <UniformComposition dataClient={new DefaultDataClient()} />
185
+ if (ts.isJsxAttribute(node) && node.name.getText(sf) === "dataClient") {
186
+ const tag = node.parent.parent;
187
+ const expression = node.initializer && ts.isJsxExpression(node.initializer) ? node.initializer.expression : undefined;
188
+ if (ts.isIdentifier(tag.tagName) && sdkLocals.has(tag.tagName.text) && isDefaultDataClient(expression)) {
189
+ removals.push({ jsx: node });
190
+ record(node, "removed-option", 'Remove the prop "dataClient={new DefaultDataClient()}". It set the default data client.');
191
+ }
192
+ }
193
+
194
+ // The server configuration.
195
+ if (ts.isObjectLiteralExpression(node) && isServerConfigObject(node, isConfigFile)) {
196
+ for (const property of node.properties) {
197
+ const name = property.name && propertyName(property.name);
198
+ if (name === "middlewareRuntimeCache") {
199
+ removals.push({ list: node.properties, element: property, owner: node });
200
+ record(property, "removed-config-key", 'Remove "middlewareRuntimeCache". The middleware makes no API calls.');
201
+ }
202
+ if (name === "experimental" && ts.isPropertyAssignment(property) && ts.isObjectLiteralExpression(property.initializer)) {
203
+ const inner = property.initializer.properties;
204
+ const flags = inner.filter((p) => p.name && propertyName(p.name) === "disableSwrMiddlewareCache");
205
+ if (!flags.length) continue;
206
+ if (flags.length === inner.length) {
207
+ removals.push({ list: node.properties, element: property, owner: node });
208
+ record(property, "removed-config-key", 'Remove "experimental: { disableSwrMiddlewareCache }". The middleware makes no API calls.');
209
+ } else {
210
+ for (const flag of flags) {
211
+ removals.push({ list: inner, element: flag, owner: property.initializer });
212
+ record(flag, "removed-config-key", 'Remove "experimental.disableSwrMiddlewareCache". The middleware makes no API calls.');
213
+ }
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ ts.forEachChild(node, visit);
220
+ };
221
+ visit(sf);
222
+
223
+ const edits = [...editsForRemovals(sf, removals), ...editsForClientContext(sf, sdkLocals, record)];
224
+ if (!edits.length) return { text, changes: [], importsVercelFunctions };
225
+
226
+ // Remove the imports of removed exports that the changes leave without a reference.
227
+ const removedRanges = edits.map((edit) => [edit.start, edit.end]);
228
+ const unusedImports = [];
229
+ for (const [local, info] of sdkLocals) {
230
+ if (!REMOVED_EXPORTS[info.module]?.[info.imported]) continue;
231
+ if (!hasReference(sf, local, info.element, removedRanges)) {
232
+ unusedImports.push(info);
233
+ record(info.element, "removed-export", `Remove the import of "${info.imported}". No code uses it after the changes.`);
234
+ }
235
+ }
236
+ edits.push(...editsForImportRemovals(sf, unusedImports));
237
+
238
+ return { text: applyEdits(text, edits), changes, importsVercelFunctions };
239
+ }
240
+
241
+ // The SDK now sends the composition metadata of the page composition to the Context, so the
242
+ // compositionMetadata argument of useInitUniformContext and its prop have no effect.
243
+ function editsForClientContext(sf, sdkLocals, record) {
244
+ const text = sf.getFullText();
245
+ const { hookArguments, props } = findClientContextIssues(sf, sdkLocals);
246
+ const edits = [];
247
+ for (const { call, argument, removable } of hookArguments) {
248
+ if (!removable) continue;
249
+ let end = argument.end;
250
+ const comma = /^\s*,/.exec(text.slice(end));
251
+ if (comma) end += comma[0].length;
252
+ edits.push({ start: call.arguments[0].end, end, text: "" });
253
+ record(argument, "context-composition-metadata", "Remove the compositionMetadata argument of useInitUniformContext. The SDK sends the metadata of the page composition.");
254
+ }
255
+
256
+ const removedRanges = edits.map((edit) => [edit.start, edit.end]);
257
+ const byPattern = new Map();
258
+ for (const { element, pattern, fn, name } of props) {
259
+ if (name !== COMPOSITION_METADATA || !ts.isIdentifier(element.name)) continue;
260
+ if (hasReference(fn, element.name.text, element, removedRanges, sf)) continue;
261
+ if (!byPattern.has(pattern)) byPattern.set(pattern, new Set());
262
+ byPattern.get(pattern).add(element);
263
+ record(element, "context-composition-metadata", "Remove the compositionMetadata prop. ClientContextComponentProps no longer has it.");
264
+ }
265
+ for (const [pattern, elements] of byPattern) {
266
+ edits.push(...editsForListRemoval(sf, pattern.elements, elements, () => ({ start: pattern.getStart(sf), end: pattern.end, text: "{}" })));
267
+ }
268
+ return edits;
269
+ }
270
+
271
+ function propertyName(name) {
272
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
273
+ return undefined;
274
+ }
275
+
276
+ function isServerConfigObject(node, isConfigFile) {
277
+ const parent = node.parent;
278
+ const typed = (type) => type && /\bUniformServerConfig\b/.test(type.getText());
279
+ if (ts.isVariableDeclaration(parent) && typed(parent.type)) return true;
280
+ if (ts.isSatisfiesExpression?.(parent) && typed(parent.type)) return true;
281
+ if (ts.isAsExpression(parent) && typed(parent.type)) return true;
282
+ if (isConfigFile && ts.isExportAssignment(parent)) return true;
283
+ return false;
284
+ }
285
+
286
+ // True when `local` has a reference in `scope` outside its declaration and outside the removed ranges.
287
+ function hasReference(scope, local, declaration, removedRanges, sf = scope) {
288
+ let found = false;
289
+ const inside = (pos) => removedRanges.some(([start, end]) => pos >= start && pos < end);
290
+ const visit = (node) => {
291
+ if (found) return;
292
+ if (ts.isImportDeclaration(node)) return;
293
+ if (ts.isIdentifier(node) && node.text === local && node !== declaration.name && !inside(node.getStart(sf))) {
294
+ // Skip the name part of a property access (x.local) and property names ({ local: x }).
295
+ const parent = node.parent;
296
+ const isMemberName = (ts.isPropertyAccessExpression(parent) && parent.name === node) || (ts.isPropertyAssignment(parent) && parent.name === node);
297
+ if (!isMemberName) found = true;
298
+ }
299
+ ts.forEachChild(node, visit);
300
+ };
301
+ visit(scope);
302
+ return found;
303
+ }
304
+
305
+ // Edits
306
+
307
+ /** Turns the removals into text edits that keep commas and indentation correct. */
308
+ function editsForRemovals(sf, removals) {
309
+ const edits = [];
310
+ const byList = new Map();
311
+ for (const removal of removals) {
312
+ if (removal.jsx) {
313
+ edits.push({ start: removal.jsx.getFullStart(), end: removal.jsx.end, text: "" });
314
+ continue;
315
+ }
316
+ if (!byList.has(removal.list)) byList.set(removal.list, { owner: removal.owner, elements: new Set() });
317
+ byList.get(removal.list).elements.add(removal.element);
318
+ }
319
+
320
+ // A removal inside a property that is also removed is not necessary.
321
+ const removedElements = [...byList.values()].flatMap((group) => [...group.elements]);
322
+ const isInsideRemoved = (owner) => removedElements.some((element) => owner.pos >= element.pos && owner.end <= element.end && owner !== element);
323
+
324
+ for (const [list, { owner, elements }] of byList) {
325
+ if (isInsideRemoved(owner)) continue;
326
+ edits.push(...editsForListRemoval(sf, list, elements, () => ({ start: owner.getStart(sf), end: owner.end, text: "{}" })));
327
+ }
328
+ return edits;
329
+ }
330
+
331
+ function editsForListRemoval(sf, list, removed, whenEmpty) {
332
+ const items = [...list];
333
+ let lastKept = -1;
334
+ items.forEach((item, i) => {
335
+ if (!removed.has(item)) lastKept = i;
336
+ });
337
+ if (lastKept === -1) return [whenEmpty()];
338
+
339
+ const edits = [];
340
+ items.forEach((item, i) => {
341
+ if (removed.has(item) && i < lastKept) edits.push({ start: item.getStart(sf), end: items[i + 1].getStart(sf), text: "" });
342
+ });
343
+ if (lastKept < items.length - 1) edits.push({ start: items[lastKept].end, end: items[items.length - 1].end, text: "" });
344
+ return edits;
345
+ }
346
+
347
+ function editsForImportRemovals(sf, unusedImports) {
348
+ const edits = [];
349
+ const byStatement = new Map();
350
+ for (const info of unusedImports) {
351
+ if (!byStatement.has(info.statement)) byStatement.set(info.statement, new Set());
352
+ byStatement.get(info.statement).add(info.element);
353
+ }
354
+ for (const [statement, elements] of byStatement) {
355
+ const clause = statement.importClause;
356
+ const named = clause.namedBindings;
357
+ edits.push(
358
+ ...editsForListRemoval(sf, named.elements, elements, () => {
359
+ if (clause.name) return { start: clause.name.end, end: named.end, text: "" };
360
+ // Remove the full import statement and its line break.
361
+ const text = sf.getFullText();
362
+ let end = statement.end;
363
+ if (text[end] === "\r") end++;
364
+ if (text[end] === "\n") end++;
365
+ return { start: statement.getStart(sf), end, text: "" };
366
+ })
367
+ );
368
+ }
369
+ return edits;
370
+ }
371
+
372
+ export function applyEdits(text, edits) {
373
+ const sorted = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
374
+ for (let i = 1; i < sorted.length; i++) {
375
+ if (sorted[i].end > sorted[i - 1].start) throw new Error("Internal error: two changes overlap in one file.");
376
+ }
377
+ let out = text;
378
+ for (const edit of sorted) out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
379
+ return out;
380
+ }
381
+
382
+ /** Returns the number of syntax errors that TypeScript finds when it parses the text. */
383
+ export function countSyntaxErrors(file, text) {
384
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(file));
385
+ return sf.parseDiagnostics?.length ?? 0;
386
+ }
@@ -0,0 +1,25 @@
1
+ // Asks the npm registry which packages exist at a version.
2
+
3
+ const REGISTRY = process.env.npm_config_registry?.replace(/\/$/, "") || "https://registry.npmjs.org";
4
+
5
+ /**
6
+ * Returns a Map from package name to true (published), false (not published) or undefined (the
7
+ * registry did not answer).
8
+ */
9
+ export async function publishedAt(names, version, { timeoutMs = 10000 } = {}) {
10
+ const entries = await Promise.all(
11
+ names.map(async (name) => {
12
+ try {
13
+ const response = await fetch(`${REGISTRY}/${name.replace("/", "%2f")}/${encodeURIComponent(version)}`, {
14
+ signal: AbortSignal.timeout(timeoutMs),
15
+ headers: { accept: "application/json" },
16
+ });
17
+ if (response.status === 404) return [name, false];
18
+ return [name, response.ok ? true : undefined];
19
+ } catch {
20
+ return [name, undefined];
21
+ }
22
+ })
23
+ );
24
+ return new Map(entries);
25
+ }