@uniformdev/next-app-router-upgrade 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/README.md +248 -2
- package/bin/cli.js +167 -0
- package/package.json +35 -4
- package/src/apply.js +124 -0
- package/src/edge/index.js +37 -0
- package/src/edge/vercel.js +360 -0
- package/src/interactive.js +111 -0
- package/src/json-text.js +95 -0
- package/src/migrate.js +356 -0
- package/src/registry.js +25 -0
- package/src/report.js +44 -0
- package/src/rules.js +156 -0
- package/src/scan.js +443 -0
- package/src/session.js +194 -0
package/src/migrate.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
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 { TARGET_VERSION } from "./report.js";
|
|
10
|
+
import { addToBlock, setValueInBlock } from "./json-text.js";
|
|
11
|
+
import { REMOVED_EXPORTS } from "./rules.js";
|
|
12
|
+
import { listSourceFiles, scriptKind } from "./scan.js";
|
|
13
|
+
|
|
14
|
+
const SDK = "@uniformdev/next-app-router";
|
|
15
|
+
// These packages have their own version line. The registry check also finds such packages.
|
|
16
|
+
const SEPARATE_VERSION_LINE = new Set(["@uniformdev/toolbar-react", "@uniformdev/context-edge"]);
|
|
17
|
+
// 20.82.0 installed this version of @vercel/functions as a dependency of the SDK.
|
|
18
|
+
const VERCEL_FUNCTIONS_RANGE = "^3.7.2";
|
|
19
|
+
const DEFAULT_DATA_CLIENT = "DefaultDataClient";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns { changes, contents, manual } for the project at `root`.
|
|
23
|
+
* `changes` lists each change with its file, line and description.
|
|
24
|
+
* `contents` maps each changed absolute path to its new text. Nothing is written.
|
|
25
|
+
* `manual` lists package.json steps that the migration could not do.
|
|
26
|
+
* `published` maps @uniformdev package names to true, false or undefined (refer to registry.js).
|
|
27
|
+
* Without it, the plan assumes that each package exists at the target version.
|
|
28
|
+
*/
|
|
29
|
+
export function planMigration(root, { published } = {}) {
|
|
30
|
+
const changes = [];
|
|
31
|
+
const contents = new Map();
|
|
32
|
+
|
|
33
|
+
const sourceFiles = listSourceFiles(root);
|
|
34
|
+
const importsVercelFunctions = [];
|
|
35
|
+
for (const file of sourceFiles) {
|
|
36
|
+
const text = fs.readFileSync(file, "utf8");
|
|
37
|
+
const result = migrateSourceFile(root, file, text);
|
|
38
|
+
if (result.importsVercelFunctions) importsVercelFunctions.push(file);
|
|
39
|
+
if (result.text !== text) {
|
|
40
|
+
contents.set(file, result.text);
|
|
41
|
+
changes.push(...result.changes);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const pkgFile = path.join(root, "package.json");
|
|
46
|
+
const pkgText = fs.readFileSync(pkgFile, "utf8");
|
|
47
|
+
const pkgResult = migratePackageJson(pkgText, {
|
|
48
|
+
importsVercelFunctions: importsVercelFunctions.length > 0,
|
|
49
|
+
packageManager: detectPackageManager(root),
|
|
50
|
+
published,
|
|
51
|
+
});
|
|
52
|
+
if (pkgResult.text !== pkgText) {
|
|
53
|
+
contents.set(pkgFile, pkgResult.text);
|
|
54
|
+
}
|
|
55
|
+
changes.push(...pkgResult.changes.map((change) => ({ file: "package.json", ...change })));
|
|
56
|
+
|
|
57
|
+
return { changes, contents, manual: pkgResult.manual };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The @uniformdev dependencies that the migration sets to the target version. */
|
|
61
|
+
export function uniformDependencies(pkg) {
|
|
62
|
+
const names = new Set();
|
|
63
|
+
for (const field of ["dependencies", "devDependencies"]) {
|
|
64
|
+
for (const [name, version] of Object.entries(pkg[field] ?? {})) {
|
|
65
|
+
if (name.startsWith("@uniformdev/") && !SEPARATE_VERSION_LINE.has(name) && !/^(file|link|workspace|git|github|https?):/.test(version)) names.add(name);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [...names];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function detectPackageManager(root) {
|
|
72
|
+
if (fs.existsSync(path.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
73
|
+
if (fs.existsSync(path.join(root, "yarn.lock"))) return "yarn";
|
|
74
|
+
return "npm";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// package.json
|
|
78
|
+
|
|
79
|
+
export function migratePackageJson(text, { importsVercelFunctions, packageManager = "npm", published }) {
|
|
80
|
+
const pkg = JSON.parse(text);
|
|
81
|
+
const changes = [];
|
|
82
|
+
const manual = [];
|
|
83
|
+
let out = text;
|
|
84
|
+
|
|
85
|
+
for (const name of uniformDependencies(pkg)) {
|
|
86
|
+
const isPublished = published ? published.get(name) : true;
|
|
87
|
+
if (isPublished === false) continue; // This package has its own version line.
|
|
88
|
+
if (isPublished === undefined) {
|
|
89
|
+
manual.push(`Could not ask the npm registry if ${name}@${TARGET_VERSION} exists. Set its version yourself.`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
for (const field of ["dependencies", "devDependencies"]) {
|
|
93
|
+
const version = pkg[field]?.[name];
|
|
94
|
+
if (!version || version === TARGET_VERSION) continue;
|
|
95
|
+
out = setValueInBlock(out, field, name, TARGET_VERSION) ?? out;
|
|
96
|
+
changes.push({ rule: "version", description: `Set ${name} from ${version} to ${TARGET_VERSION}.` });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const listed = pkg.dependencies?.["@vercel/functions"] || pkg.devDependencies?.["@vercel/functions"];
|
|
101
|
+
if (importsVercelFunctions && !listed) {
|
|
102
|
+
out = addToBlock(out, "dependencies", "@vercel/functions", VERCEL_FUNCTIONS_RANGE);
|
|
103
|
+
changes.push({
|
|
104
|
+
rule: "vercel-functions-transitive",
|
|
105
|
+
description: `Add @vercel/functions ${VERCEL_FUNCTIONS_RANGE} to dependencies. Version 20.82 installed it for you.`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A prerelease does not match the ^20.x peer range of @uniformdev/toolbar-react. Without an
|
|
110
|
+
// override, npm fails with ERESOLVE, and the app can get two copies of @uniformdev/context.
|
|
111
|
+
const usesToolbar = pkg.dependencies?.["@uniformdev/toolbar-react"] || pkg.devDependencies?.["@uniformdev/toolbar-react"];
|
|
112
|
+
if (TARGET_VERSION.includes("-") && usesToolbar) {
|
|
113
|
+
const field = { npm: "overrides", yarn: "resolutions" }[packageManager];
|
|
114
|
+
if (!field) {
|
|
115
|
+
manual.push(`Add "@uniformdev/context": "${TARGET_VERSION}" to the pnpm overrides, so that @uniformdev/toolbar-react accepts the prerelease version.`);
|
|
116
|
+
} else if (!pkg[field]?.["@uniformdev/context"]) {
|
|
117
|
+
out = addToBlock(out, field, "@uniformdev/context", TARGET_VERSION);
|
|
118
|
+
changes.push({
|
|
119
|
+
rule: "prerelease-peer-range",
|
|
120
|
+
description: `Add "@uniformdev/context": "${TARGET_VERSION}" to ${field}. @uniformdev/toolbar-react does not accept a prerelease version without it.`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { text: out, changes, manual };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Source files
|
|
129
|
+
|
|
130
|
+
export function migrateSourceFile(root, file, text) {
|
|
131
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(file));
|
|
132
|
+
const rel = path.relative(root, file);
|
|
133
|
+
const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
134
|
+
const changes = [];
|
|
135
|
+
const removals = []; // { list, element } items, grouped by list later.
|
|
136
|
+
const record = (node, rule, description) => changes.push({ file: rel, line: lineOf(node), rule, description });
|
|
137
|
+
|
|
138
|
+
const sdkLocals = new Map();
|
|
139
|
+
let importsVercelFunctions = false;
|
|
140
|
+
for (const statement of sf.statements) {
|
|
141
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
142
|
+
const module = statement.moduleSpecifier.text;
|
|
143
|
+
if (module === "@vercel/functions" || module.startsWith("@vercel/functions/")) importsVercelFunctions = true;
|
|
144
|
+
if (!module.startsWith(SDK)) continue;
|
|
145
|
+
const bindings = statement.importClause?.namedBindings;
|
|
146
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
147
|
+
for (const element of bindings.elements) {
|
|
148
|
+
sdkLocals.set(element.name.text, { module, imported: (element.propertyName ?? element.name).text, element, statement });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const isConfigFile = /^uniform\.server\.config\.[cm]?[jt]s$/.test(path.basename(file));
|
|
154
|
+
if (!sdkLocals.size && !isConfigFile) return { text, changes, importsVercelFunctions };
|
|
155
|
+
|
|
156
|
+
const isSdkCallee = (expression) => ts.isIdentifier(expression) && sdkLocals.has(expression.text);
|
|
157
|
+
const isDefaultDataClient = (expression) =>
|
|
158
|
+
expression &&
|
|
159
|
+
ts.isNewExpression(expression) &&
|
|
160
|
+
ts.isIdentifier(expression.expression) &&
|
|
161
|
+
sdkLocals.get(expression.expression.text)?.imported === DEFAULT_DATA_CLIENT &&
|
|
162
|
+
!(expression.arguments ?? []).length;
|
|
163
|
+
|
|
164
|
+
const visit = (node) => {
|
|
165
|
+
// Options of SDK functions: uniformMiddleware({ ... }), resolveRouteFromCode({ ... }) and others.
|
|
166
|
+
if (ts.isCallExpression(node) && isSdkCallee(node.expression)) {
|
|
167
|
+
for (const argument of node.arguments) {
|
|
168
|
+
if (!ts.isObjectLiteralExpression(argument)) continue;
|
|
169
|
+
for (const property of argument.properties) {
|
|
170
|
+
const name = property.name && propertyName(property.name);
|
|
171
|
+
if (name === "pathPatternsWithVariations") {
|
|
172
|
+
removals.push({ list: argument.properties, element: property, owner: argument });
|
|
173
|
+
record(property, "removed-option", 'Remove the "pathPatternsWithVariations" option. The middleware no longer evaluates variants.');
|
|
174
|
+
}
|
|
175
|
+
if (name === "dataClient" && ts.isPropertyAssignment(property) && isDefaultDataClient(property.initializer)) {
|
|
176
|
+
removals.push({ list: argument.properties, element: property, owner: argument });
|
|
177
|
+
record(property, "removed-option", 'Remove "dataClient: new DefaultDataClient()". It set the default data client.');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// <UniformComposition dataClient={new DefaultDataClient()} />
|
|
184
|
+
if (ts.isJsxAttribute(node) && node.name.getText(sf) === "dataClient") {
|
|
185
|
+
const tag = node.parent.parent;
|
|
186
|
+
const expression = node.initializer && ts.isJsxExpression(node.initializer) ? node.initializer.expression : undefined;
|
|
187
|
+
if (ts.isIdentifier(tag.tagName) && sdkLocals.has(tag.tagName.text) && isDefaultDataClient(expression)) {
|
|
188
|
+
removals.push({ jsx: node });
|
|
189
|
+
record(node, "removed-option", 'Remove the prop "dataClient={new DefaultDataClient()}". It set the default data client.');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The server configuration.
|
|
194
|
+
if (ts.isObjectLiteralExpression(node) && isServerConfigObject(node, isConfigFile)) {
|
|
195
|
+
for (const property of node.properties) {
|
|
196
|
+
const name = property.name && propertyName(property.name);
|
|
197
|
+
if (name === "middlewareRuntimeCache") {
|
|
198
|
+
removals.push({ list: node.properties, element: property, owner: node });
|
|
199
|
+
record(property, "removed-config-key", 'Remove "middlewareRuntimeCache". The middleware makes no API calls.');
|
|
200
|
+
}
|
|
201
|
+
if (name === "experimental" && ts.isPropertyAssignment(property) && ts.isObjectLiteralExpression(property.initializer)) {
|
|
202
|
+
const inner = property.initializer.properties;
|
|
203
|
+
const flags = inner.filter((p) => p.name && propertyName(p.name) === "disableSwrMiddlewareCache");
|
|
204
|
+
if (!flags.length) continue;
|
|
205
|
+
if (flags.length === inner.length) {
|
|
206
|
+
removals.push({ list: node.properties, element: property, owner: node });
|
|
207
|
+
record(property, "removed-config-key", 'Remove "experimental: { disableSwrMiddlewareCache }". The middleware makes no API calls.');
|
|
208
|
+
} else {
|
|
209
|
+
for (const flag of flags) {
|
|
210
|
+
removals.push({ list: inner, element: flag, owner: property.initializer });
|
|
211
|
+
record(flag, "removed-config-key", 'Remove "experimental.disableSwrMiddlewareCache". The middleware makes no API calls.');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
ts.forEachChild(node, visit);
|
|
219
|
+
};
|
|
220
|
+
visit(sf);
|
|
221
|
+
|
|
222
|
+
if (!removals.length) return { text, changes: [], importsVercelFunctions };
|
|
223
|
+
|
|
224
|
+
const edits = editsForRemovals(sf, removals);
|
|
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
|
+
function propertyName(name) {
|
|
242
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isServerConfigObject(node, isConfigFile) {
|
|
247
|
+
const parent = node.parent;
|
|
248
|
+
const typed = (type) => type && /\bUniformServerConfig\b/.test(type.getText());
|
|
249
|
+
if (ts.isVariableDeclaration(parent) && typed(parent.type)) return true;
|
|
250
|
+
if (ts.isSatisfiesExpression?.(parent) && typed(parent.type)) return true;
|
|
251
|
+
if (ts.isAsExpression(parent) && typed(parent.type)) return true;
|
|
252
|
+
if (isConfigFile && ts.isExportAssignment(parent)) return true;
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// True when `local` has a reference in the file outside the import and outside the removed ranges.
|
|
257
|
+
function hasReference(sf, local, importElement, removedRanges) {
|
|
258
|
+
let found = false;
|
|
259
|
+
const inside = (pos) => removedRanges.some(([start, end]) => pos >= start && pos < end);
|
|
260
|
+
const visit = (node) => {
|
|
261
|
+
if (found) return;
|
|
262
|
+
if (ts.isImportDeclaration(node)) return;
|
|
263
|
+
if (ts.isIdentifier(node) && node.text === local && node !== importElement.name && !inside(node.getStart(sf))) {
|
|
264
|
+
// Skip the name part of a property access (x.local) and property names ({ local: x }).
|
|
265
|
+
const parent = node.parent;
|
|
266
|
+
const isMemberName = (ts.isPropertyAccessExpression(parent) && parent.name === node) || (ts.isPropertyAssignment(parent) && parent.name === node);
|
|
267
|
+
if (!isMemberName) found = true;
|
|
268
|
+
}
|
|
269
|
+
ts.forEachChild(node, visit);
|
|
270
|
+
};
|
|
271
|
+
visit(sf);
|
|
272
|
+
return found;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Edits
|
|
276
|
+
|
|
277
|
+
/** Turns the removals into text edits that keep commas and indentation correct. */
|
|
278
|
+
function editsForRemovals(sf, removals) {
|
|
279
|
+
const edits = [];
|
|
280
|
+
const byList = new Map();
|
|
281
|
+
for (const removal of removals) {
|
|
282
|
+
if (removal.jsx) {
|
|
283
|
+
edits.push({ start: removal.jsx.getFullStart(), end: removal.jsx.end, text: "" });
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (!byList.has(removal.list)) byList.set(removal.list, { owner: removal.owner, elements: new Set() });
|
|
287
|
+
byList.get(removal.list).elements.add(removal.element);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// A removal inside a property that is also removed is not necessary.
|
|
291
|
+
const removedElements = [...byList.values()].flatMap((group) => [...group.elements]);
|
|
292
|
+
const isInsideRemoved = (owner) => removedElements.some((element) => owner.pos >= element.pos && owner.end <= element.end && owner !== element);
|
|
293
|
+
|
|
294
|
+
for (const [list, { owner, elements }] of byList) {
|
|
295
|
+
if (isInsideRemoved(owner)) continue;
|
|
296
|
+
edits.push(...editsForListRemoval(sf, list, elements, () => ({ start: owner.getStart(sf), end: owner.end, text: "{}" })));
|
|
297
|
+
}
|
|
298
|
+
return edits;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function editsForListRemoval(sf, list, removed, whenEmpty) {
|
|
302
|
+
const items = [...list];
|
|
303
|
+
let lastKept = -1;
|
|
304
|
+
items.forEach((item, i) => {
|
|
305
|
+
if (!removed.has(item)) lastKept = i;
|
|
306
|
+
});
|
|
307
|
+
if (lastKept === -1) return [whenEmpty()];
|
|
308
|
+
|
|
309
|
+
const edits = [];
|
|
310
|
+
items.forEach((item, i) => {
|
|
311
|
+
if (removed.has(item) && i < lastKept) edits.push({ start: item.getStart(sf), end: items[i + 1].getStart(sf), text: "" });
|
|
312
|
+
});
|
|
313
|
+
if (lastKept < items.length - 1) edits.push({ start: items[lastKept].end, end: items[items.length - 1].end, text: "" });
|
|
314
|
+
return edits;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function editsForImportRemovals(sf, unusedImports) {
|
|
318
|
+
const edits = [];
|
|
319
|
+
const byStatement = new Map();
|
|
320
|
+
for (const info of unusedImports) {
|
|
321
|
+
if (!byStatement.has(info.statement)) byStatement.set(info.statement, new Set());
|
|
322
|
+
byStatement.get(info.statement).add(info.element);
|
|
323
|
+
}
|
|
324
|
+
for (const [statement, elements] of byStatement) {
|
|
325
|
+
const clause = statement.importClause;
|
|
326
|
+
const named = clause.namedBindings;
|
|
327
|
+
edits.push(
|
|
328
|
+
...editsForListRemoval(sf, named.elements, elements, () => {
|
|
329
|
+
if (clause.name) return { start: clause.name.end, end: named.end, text: "" };
|
|
330
|
+
// Remove the full import statement and its line break.
|
|
331
|
+
const text = sf.getFullText();
|
|
332
|
+
let end = statement.end;
|
|
333
|
+
if (text[end] === "\r") end++;
|
|
334
|
+
if (text[end] === "\n") end++;
|
|
335
|
+
return { start: statement.getStart(sf), end, text: "" };
|
|
336
|
+
})
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return edits;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function applyEdits(text, edits) {
|
|
343
|
+
const sorted = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
|
|
344
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
345
|
+
if (sorted[i].end > sorted[i - 1].start) throw new Error("Internal error: two changes overlap in one file.");
|
|
346
|
+
}
|
|
347
|
+
let out = text;
|
|
348
|
+
for (const edit of sorted) out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Returns the number of syntax errors that TypeScript finds when it parses the text. */
|
|
353
|
+
export function countSyntaxErrors(file, text) {
|
|
354
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(file));
|
|
355
|
+
return sf.parseDiagnostics?.length ?? 0;
|
|
356
|
+
}
|
package/src/registry.js
ADDED
|
@@ -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
|
+
}
|
package/src/report.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const ORDER = { error: 0, warning: 1, info: 2 };
|
|
2
|
+
const LABEL = { error: "CODE CHANGE NEEDED", warning: "REVIEW", info: "NOTE" };
|
|
3
|
+
|
|
4
|
+
export const TARGET_VERSION = "20.81.1-alpha.25.sha-f076f9b857";
|
|
5
|
+
|
|
6
|
+
export function verdict(findings, { strict = false } = {}) {
|
|
7
|
+
const errors = findings.filter((f) => f.severity === "error").length;
|
|
8
|
+
const warnings = findings.filter((f) => f.severity === "warning").length;
|
|
9
|
+
if (errors || (strict && warnings)) return { safe: false, label: "CODE CHANGES NEEDED", errors, warnings };
|
|
10
|
+
return { safe: true, label: warnings ? "SAFE TO INSTALL (review the behavior changes)" : "SAFE TO INSTALL", errors, warnings };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatText({ project, findings }, result, { color = true } = {}) {
|
|
14
|
+
const c = (code, s) => (color ? `\u001b[${code}m${s}\u001b[0m` : s);
|
|
15
|
+
const sev = { error: (s) => c("31;1", s), warning: (s) => c("33;1", s), info: (s) => c("36", s) };
|
|
16
|
+
const lines = [];
|
|
17
|
+
|
|
18
|
+
lines.push(c("1", "Uniform Next.js App Router SDK upgrade check"));
|
|
19
|
+
lines.push(`Target version: ${TARGET_VERSION}`);
|
|
20
|
+
lines.push(`Project: ${project.name ?? "(no name)"} at ${project.root}`);
|
|
21
|
+
lines.push(`Installed @uniformdev/next-app-router: ${project.sdkVersion ?? "not found"}`);
|
|
22
|
+
lines.push(`Files scanned: ${project.filesScanned}`);
|
|
23
|
+
lines.push("");
|
|
24
|
+
|
|
25
|
+
const sorted = [...findings].sort((a, b) => ORDER[a.severity] - ORDER[b.severity] || (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0));
|
|
26
|
+
let current;
|
|
27
|
+
for (const f of sorted) {
|
|
28
|
+
if (f.severity !== current) {
|
|
29
|
+
current = f.severity;
|
|
30
|
+
lines.push(sev[current](`${LABEL[current]}`));
|
|
31
|
+
}
|
|
32
|
+
const where = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "";
|
|
33
|
+
lines.push(` ${sev[f.severity]("•")} ${f.title}${where ? c("2", ` (${where})`) : ""}`);
|
|
34
|
+
lines.push(` ${f.message}`);
|
|
35
|
+
lines.push(` Fix: ${f.fix}`);
|
|
36
|
+
lines.push(c("2", ` Docs: ${f.docs}`));
|
|
37
|
+
}
|
|
38
|
+
if (!findings.length) lines.push("No findings.");
|
|
39
|
+
|
|
40
|
+
lines.push("");
|
|
41
|
+
lines.push(`Errors: ${result.errors} Warnings: ${result.warnings} Notes: ${findings.length - result.errors - result.warnings}`);
|
|
42
|
+
lines.push(c(result.safe ? "32;1" : "31;1", `Verdict: ${result.label}`));
|
|
43
|
+
return lines.join("\n");
|
|
44
|
+
}
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Each rule describes one change between @uniformdev/next-app-router 20.82 and the
|
|
2
|
+
// developer preview (20.81.1-alpha.25). "error" rules fail to compile or break at
|
|
3
|
+
// runtime. "warning" rules change behavior. "info" rules need no change.
|
|
4
|
+
|
|
5
|
+
const DOCS = "https://docs.uniform.app/docs/sdk/nextjs-app-router";
|
|
6
|
+
const UPGRADE = `${DOCS}/upgrade-to-developer-preview`;
|
|
7
|
+
|
|
8
|
+
export const REMOVED_EXPORTS = {
|
|
9
|
+
"@uniformdev/next-app-router": {
|
|
10
|
+
DataClient: "Resolve the composition on the page and change it before render.",
|
|
11
|
+
DefaultDataClient: "Resolve the composition on the page and change it before render.",
|
|
12
|
+
EnhanceRouteOptions: "Resolve the composition on the page and change it before render.",
|
|
13
|
+
GetRouteOptions: "No replacement.",
|
|
14
|
+
GetRouteFromMiddlewareOptions: "No replacement.",
|
|
15
|
+
GetRouteFromPageStateOptions: "No replacement.",
|
|
16
|
+
RewriteRouteOptions: "No replacement.",
|
|
17
|
+
RewriteRouteResult: "No replacement.",
|
|
18
|
+
expireMiddlewareCacheTag: "Use the onRevalidateTags option of createPreviewPOSTRouteHandler.",
|
|
19
|
+
precomputeComposition: "Use edge mode: the edge chooses the variants of each visitor.",
|
|
20
|
+
},
|
|
21
|
+
"@uniformdev/next-app-router-shared": {
|
|
22
|
+
getRuleId: "No replacement.",
|
|
23
|
+
resolveComponentFromPageState: "No replacement.",
|
|
24
|
+
resolveRuleFromPageState: "No replacement.",
|
|
25
|
+
PageStateComponent: "No replacement.",
|
|
26
|
+
PageStateComponentFields: "No replacement.",
|
|
27
|
+
},
|
|
28
|
+
"@uniformdev/next-app-router-client": {
|
|
29
|
+
ClientContextTestTransfer: "No replacement.",
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const REMOVED_OPTIONS = {
|
|
34
|
+
dataClient: "Remove it. Resolve the composition on the page and change it before render.",
|
|
35
|
+
pathPatternsWithVariations: "Remove it. The middleware no longer evaluates variants.",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const REMOVED_CONFIG_KEYS = {
|
|
39
|
+
middlewareRuntimeCache: "Remove it. The middleware makes no API calls.",
|
|
40
|
+
disableSwrMiddlewareCache: "Remove experimental.disableSwrMiddlewareCache. The middleware makes no API calls.",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const REMOVED_PAGE_STATE_FIELDS = ["components", "rules", "quirks", "isPrefetch", "requestPath"];
|
|
44
|
+
|
|
45
|
+
export const RULES = {
|
|
46
|
+
"removed-export": {
|
|
47
|
+
severity: "error",
|
|
48
|
+
title: "Import of an API that the developer preview removes",
|
|
49
|
+
docs: `${UPGRADE}#step-5-replace-the-removed-apis`,
|
|
50
|
+
},
|
|
51
|
+
"removed-option": {
|
|
52
|
+
severity: "error",
|
|
53
|
+
title: "Option or prop that the developer preview removes",
|
|
54
|
+
docs: `${UPGRADE}#step-2-remove-the-removed-middleware-options`,
|
|
55
|
+
},
|
|
56
|
+
"removed-config-key": {
|
|
57
|
+
severity: "error",
|
|
58
|
+
title: "Server configuration option that the developer preview removes",
|
|
59
|
+
docs: `${UPGRADE}#step-4-clean-up-the-server-configuration`,
|
|
60
|
+
},
|
|
61
|
+
"removed-page-state-field": {
|
|
62
|
+
severity: "error",
|
|
63
|
+
title: "Page state field that the developer preview removes",
|
|
64
|
+
docs: `${UPGRADE}#step-5-replace-the-removed-apis`,
|
|
65
|
+
},
|
|
66
|
+
"result-code": {
|
|
67
|
+
severity: "error",
|
|
68
|
+
title: "Use of result.code from a route resolver",
|
|
69
|
+
docs: `${UPGRADE}#step-5-replace-the-removed-apis`,
|
|
70
|
+
},
|
|
71
|
+
"old-page-code": {
|
|
72
|
+
severity: "error",
|
|
73
|
+
title: "Hard-coded page code in the old format (2~)",
|
|
74
|
+
docs: `${UPGRADE}#behavior-changes`,
|
|
75
|
+
},
|
|
76
|
+
"next-version": {
|
|
77
|
+
severity: "error",
|
|
78
|
+
title: "Next.js version is lower than 16.0.7",
|
|
79
|
+
docs: `${DOCS}#prerequisites`,
|
|
80
|
+
},
|
|
81
|
+
"vercel-functions-transitive": {
|
|
82
|
+
severity: "error",
|
|
83
|
+
title: "@vercel/functions is imported but not a dependency",
|
|
84
|
+
docs: `${UPGRADE}#step-7-install-the-vercel-functions-package`,
|
|
85
|
+
},
|
|
86
|
+
"route-union": {
|
|
87
|
+
severity: "warning",
|
|
88
|
+
title: "Direct access to route.compositionApiResponse",
|
|
89
|
+
docs: `${DOCS}/resolving-compositions#the-result`,
|
|
90
|
+
},
|
|
91
|
+
"placement-props": {
|
|
92
|
+
severity: "warning",
|
|
93
|
+
title: "Use of PersonalizeProps or TestProps",
|
|
94
|
+
docs: `${UPGRADE}#behavior-changes`,
|
|
95
|
+
},
|
|
96
|
+
"lite-mode-flicker": {
|
|
97
|
+
severity: "warning",
|
|
98
|
+
title: "Personalizations and tests change from middleware evaluation to the browser (lite mode)",
|
|
99
|
+
docs: `${DOCS}/edge-personalization`,
|
|
100
|
+
},
|
|
101
|
+
"project-map-query-strings": {
|
|
102
|
+
severity: "warning",
|
|
103
|
+
title: "Project map nodes with query strings",
|
|
104
|
+
docs: `${DOCS}/middleware#keep-query-strings-in-the-route`,
|
|
105
|
+
},
|
|
106
|
+
"visibility-rules": {
|
|
107
|
+
severity: "warning",
|
|
108
|
+
title: "Components with visibility rules are not in the server HTML",
|
|
109
|
+
docs: `${DOCS}/personalization#visibility-rules`,
|
|
110
|
+
},
|
|
111
|
+
"not-found-page": {
|
|
112
|
+
severity: "warning",
|
|
113
|
+
title: "Missing routes no longer rewrite to /404",
|
|
114
|
+
docs: `${DOCS}/recipes/not-found`,
|
|
115
|
+
},
|
|
116
|
+
"not-found-html": {
|
|
117
|
+
severity: "warning",
|
|
118
|
+
title: "The HTML of the 404 page is empty until hydration",
|
|
119
|
+
docs: `${DOCS}/recipes/not-found`,
|
|
120
|
+
},
|
|
121
|
+
"per-request-consent": {
|
|
122
|
+
severity: "warning",
|
|
123
|
+
title: "defaultConsent is set for each request in the middleware",
|
|
124
|
+
docs: `${DOCS}/incremental-static-regeneration-isr#consent-and-edge-mode-values`,
|
|
125
|
+
},
|
|
126
|
+
"uniform-version-mismatch": {
|
|
127
|
+
severity: "warning",
|
|
128
|
+
title: "@uniformdev packages have different versions",
|
|
129
|
+
docs: `${UPGRADE}#step-1-install-the-packages`,
|
|
130
|
+
},
|
|
131
|
+
"prerelease-peer-range": {
|
|
132
|
+
severity: "warning",
|
|
133
|
+
title: "A package has a peer range that does not accept prerelease versions",
|
|
134
|
+
docs: `${UPGRADE}#step-1-install-the-packages`,
|
|
135
|
+
},
|
|
136
|
+
"cached-resolver": {
|
|
137
|
+
severity: "info",
|
|
138
|
+
title: "The 'use cache' resolver now caches only published content",
|
|
139
|
+
docs: `${DOCS}/caching#cache-components`,
|
|
140
|
+
},
|
|
141
|
+
"static-params": {
|
|
142
|
+
severity: "info",
|
|
143
|
+
title: "createUniformStaticParams makes one code for each path",
|
|
144
|
+
docs: `${DOCS}/incremental-static-regeneration-isr`,
|
|
145
|
+
},
|
|
146
|
+
"context-edge-dependency": {
|
|
147
|
+
severity: "info",
|
|
148
|
+
title: "@uniformdev/context-edge is no longer necessary",
|
|
149
|
+
docs: `${UPGRADE}#step-1-install-the-packages`,
|
|
150
|
+
},
|
|
151
|
+
"redirect-status": {
|
|
152
|
+
severity: "info",
|
|
153
|
+
title: "Uniform redirects now come from the page (308 or 307)",
|
|
154
|
+
docs: `${UPGRADE}#behavior-changes`,
|
|
155
|
+
},
|
|
156
|
+
};
|