Files
James Anderson b859a03bbd feat(types): ship Next-compatible types without Next.js (#2612)
* feat(types): add vendored Next.js declarations

* feat(types): expose fallback types through vinext

* fix(types): support readonly Node environment declarations

* fix(ci): install vendored types before vinext tarballs

* test(types): avoid brittle packed consumer pins

* fix(ci): configure pnpm 11 local type overrides

* test(types): avoid registry metadata in packed install

* test(types): isolate packed declaration install

* feat(init): add standard script aliases for new apps

* fix(create-vinext-app): run typegen portably

* fix(typegen): report next env updates

* fix(create-vinext-app): use standard scripts only

* refactor(create-vinext-app): use exhaustive package manager return

* fix(create-vinext-app): fall back for unknown package managers
2026-07-14 13:27:20 +01:00

386 lines
13 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const require = createRequire(path.join(repoRoot, "package.json"));
const nextPackageJsonPath = require.resolve("next/package.json");
const nextRoot = fs.realpathSync(path.dirname(nextPackageJsonPath));
const nextPackage = JSON.parse(fs.readFileSync(nextPackageJsonPath, "utf-8"));
const publicNextShimMap = JSON.parse(
fs.readFileSync(path.join(repoRoot, "packages/vinext/src/shims/public-shim-map.json"), "utf-8"),
);
const outputRoot = path.join(repoRoot, "packages/types/next/upstream");
const wrapperPath = path.join(repoRoot, "packages/types/next/next-shims-upstream.generated.d.ts");
const SUPPORTED_UPSTREAM_MODULES = [
"next",
...Object.entries(publicNextShimMap)
.filter(([, definition]) => definition.types === "upstream")
.map(([specifier]) => specifier),
];
const PUBLIC_ENTRIES = resolvePublishedPublicEntries();
const EXTRA_ENTRIES = [resolvePublishedDeclaration("next/image-types/global")];
const AMBIENT_NEXT_MODULES = new Set(
["types/compiled.d.ts"].flatMap((relativePath) => {
const source = fs.readFileSync(path.join(nextRoot, relativePath), "utf-8");
return [...source.matchAll(/\bdeclare module\s+["']([^"']+)["']/g)].map((match) => match[1]);
}),
);
function isFile(filePath) {
try {
return fs.statSync(filePath).isFile();
} catch {
return false;
}
}
function packageFiles() {
if (!Array.isArray(nextPackage.files)) {
throw new Error(`next@${nextPackage.version} does not declare a package.json files list`);
}
return nextPackage.files.map((entry) => String(entry).replace(/^\.\//, "").replace(/\/$/, ""));
}
function globPattern(entry) {
let pattern = "^";
for (let index = 0; index < entry.length; index++) {
const character = entry[index];
if (character === "*" && entry[index + 1] === "*") {
pattern += ".*";
index++;
} else if (character === "*") {
pattern += "[^/]*";
} else {
pattern += character.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
}
}
return new RegExp(`${pattern}$`);
}
function isPublishedPath(relativePath) {
const normalized = relativePath.split(path.sep).join("/");
return packageFiles().some((entry) => {
if (entry === normalized) return true;
if (entry.includes("*")) return globPattern(entry).test(normalized);
return (
fs.existsSync(path.join(nextRoot, entry)) &&
fs.statSync(path.join(nextRoot, entry)).isDirectory() &&
normalized.startsWith(`${entry}/`)
);
});
}
function declarationEntryCandidates(moduleName) {
if (moduleName === "next") {
const rootTypes = nextPackage.types ?? nextPackage.typings;
if (typeof rootTypes !== "string") {
throw new Error(`next@${nextPackage.version} does not declare a package.json types entry`);
}
return [rootTypes.replace(/^\.\//, "")];
}
const subpath = moduleName.slice("next/".length);
return [`${subpath}.d.ts`, `${subpath}/index.d.ts`];
}
function resolvePublishedDeclaration(moduleName) {
const candidates = declarationEntryCandidates(moduleName);
const source = candidates.find(
(candidate) => isPublishedPath(candidate) && isFile(path.join(nextRoot, candidate)),
);
if (!source) {
throw new Error(
`Could not find a published declaration for ${JSON.stringify(moduleName)} in ` +
`next@${nextPackage.version} package.json (tried ${candidates.join(", ")})`,
);
}
return source;
}
function resolvePublishedPublicEntries() {
return SUPPORTED_UPSTREAM_MODULES.map((moduleName) => [
moduleName,
resolvePublishedDeclaration(moduleName),
]);
}
function declarationCandidates(basePath) {
const withoutJs = basePath.replace(/\.(?:mjs|cjs|js|jsx)$/, "");
return [
`${withoutJs}.d.ts`,
`${basePath}.d.ts`,
basePath,
`${basePath}.ts`,
`${withoutJs}.ts`,
path.join(basePath, "index.d.ts"),
path.join(withoutJs, "index.d.ts"),
path.join(basePath, "index.node.d.ts"),
path.join(withoutJs, "index.node.d.ts"),
];
}
function resolveNextDeclaration(fromPath, specifier) {
let basePath;
if (specifier === "next") {
basePath = path.join(nextRoot, resolvePublishedDeclaration("next"));
} else if (specifier.startsWith("next/")) {
basePath = path.join(nextRoot, specifier.slice("next/".length));
} else if (specifier.startsWith(".")) {
basePath = path.resolve(path.dirname(fromPath), specifier);
} else {
return null;
}
return declarationCandidates(basePath).find(isFile) ?? null;
}
function stripComments(source) {
return source.replace(
/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/g,
(match, stringLiteral) => stringLiteral ?? match.replace(/[^\r\n]/g, " "),
);
}
function referencedSpecifiers(source) {
const references = [];
const sourceWithoutComments = stripComments(source);
const patterns = [
/\b(?:import|export)\s+(?:type\s+)?(?:[^'";]*?\s+from\s+)?["']([^"']+)["']/g,
/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
];
for (const pattern of patterns) {
for (const match of sourceWithoutComments.matchAll(pattern)) {
const specifier = match[1];
const start = match.index + match[0].lastIndexOf(specifier);
references.push({ specifier, start, end: start + specifier.length });
}
}
for (const match of source.matchAll(
/^\s*\/\/\/\s*<reference\s+(?:path|types)=["']([^"']+)["']/gm,
)) {
const specifier = match[1];
const start = match.index + match[0].lastIndexOf(specifier);
references.push({ specifier, start, end: start + specifier.length });
}
return references;
}
function collectDeclarationClosure() {
const pending = [...PUBLIC_ENTRIES.map(([, source]) => source), ...EXTRA_ENTRIES].map((source) =>
path.join(nextRoot, source),
);
const files = new Set();
while (pending.length > 0) {
const filePath = pending.pop();
if (!filePath || files.has(filePath)) continue;
if (!isFile(filePath)) {
throw new Error(`Next.js declaration not found: ${path.relative(nextRoot, filePath)}`);
}
files.add(filePath);
const source = fs.readFileSync(filePath, "utf-8");
for (const { specifier } of referencedSpecifiers(source)) {
const resolved = resolveNextDeclaration(filePath, specifier);
if (resolved) pending.push(resolved);
else if (AMBIENT_NEXT_MODULES.has(specifier)) continue;
else if (specifier.startsWith("next/") || specifier.startsWith(".")) {
throw new Error(
`Could not resolve ${JSON.stringify(specifier)} from ${path.relative(nextRoot, filePath)}`,
);
}
}
}
return [...files].sort((a, b) => a.localeCompare(b));
}
function withoutDeclarationExtension(filePath) {
return filePath.replace(/\.d\.(?:mts|cts|ts)$/, "").replace(/\.(?:mts|cts|ts)$/, "");
}
function relativeDeclarationSpecifier(fromRelativePath, targetPath) {
const targetRelativePath = path.relative(nextRoot, targetPath).split(path.sep).join("/");
let specifier = path.posix.relative(
path.posix.dirname(fromRelativePath),
withoutDeclarationExtension(targetRelativePath),
);
if (!specifier.startsWith(".")) specifier = `./${specifier}`;
return specifier;
}
function rewriteNextSpecifiers(source, absolutePath, relativePath) {
const replacements = [];
for (const { specifier, start, end } of referencedSpecifiers(source)) {
if (specifier !== "next" && !specifier.startsWith("next/")) continue;
const target = resolveNextDeclaration(absolutePath, specifier);
if (!target && AMBIENT_NEXT_MODULES.has(specifier)) continue;
if (!target) {
throw new Error(`Could not rewrite ${JSON.stringify(specifier)} from ${relativePath}`);
}
replacements.push({ start, end, value: relativeDeclarationSpecifier(relativePath, target) });
}
let rewritten = source;
for (const { start, end, value } of replacements.sort((a, b) => b.start - a.start)) {
rewritten = rewritten.slice(0, start) + value + rewritten.slice(end);
}
return rewritten;
}
function renderVendoredFiles() {
const files = new Map();
for (const absolutePath of collectDeclarationClosure()) {
const relativePath = path.relative(nextRoot, absolutePath).split(path.sep).join("/");
const source = fs.readFileSync(absolutePath, "utf-8");
const header = `// Generated by scripts/sync-next-types.mjs from next@${nextPackage.version}: ${relativePath}\n// @generated\n`;
let rewritten = rewriteNextSpecifiers(source, absolutePath, relativePath);
rewritten = rewritten.replace(/[ \t]+(?=\r?$)/gm, "");
if (relativePath === "dist/compiled/webpack/webpack.d.ts") {
rewritten = rewritten.replace(
/\n}\s*$/,
"\n export type RuleSetUseItem = any\n" +
" export type LoaderDefinitionFunction<T = any> = any\n" +
"}\n\n" +
"export type LoaderDefinitionFunction<T = any> = any\n",
);
}
if (relativePath === "dist/build/webpack/loaders/next-app-loader/index.d.ts") {
rewritten = rewritten.replace(
/import type webpack from (['"][^'"]+['"]);/,
"import type { webpack } from $1;",
);
}
if (
relativePath === "dist/styled-jsx/types/css.d.ts" ||
relativePath === "dist/styled-jsx/types/macro.d.ts"
) {
rewritten = rewritten.replace(/\bexport\s*=\s*(\w+)/g, "export default $1");
}
files.set(relativePath, header + rewritten);
}
files.set("LICENSE.next.md", fs.readFileSync(path.join(nextRoot, "license.md"), "utf-8"));
return files;
}
function upstreamSpecifier(source) {
return `@vinext/types/next/upstream/${withoutDeclarationExtension(source)}`;
}
function hasDefaultExport(sourcePath) {
const source = stripComments(fs.readFileSync(path.join(nextRoot, sourcePath), "utf-8"));
return (
/\bexport\s+default\b/.test(source) ||
/\bexport\s*\{[^}]*\bdefault\s*(?:,|\})/.test(source) ||
/\bexport\s*\{[^}]*\bas\s+default\s*(?:,|\})/.test(source)
);
}
function renderWrappers() {
const lines = [
`// Generated by scripts/sync-next-types.mjs from next@${nextPackage.version}`,
"// @generated",
];
for (const [moduleName, source] of PUBLIC_ENTRIES) {
const target =
moduleName === "next/router" ? "@vinext/types/next/vinext/router" : upstreamSpecifier(source);
lines.push(`declare module ${JSON.stringify(moduleName)} {`);
if (moduleName === "next") {
lines.push(` export type * from ${JSON.stringify(target)};`);
lines.push("}", "");
continue;
}
lines.push(` export * from ${JSON.stringify(target)};`);
if (hasDefaultExport(source)) {
lines.push(` export { default } from ${JSON.stringify(target)};`);
}
lines.push("}", "");
}
return lines.join("\n");
}
function listFiles(root, prefix = "") {
if (!fs.existsSync(root)) return [];
const files = [];
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
const absolutePath = path.join(root, entry.name);
if (entry.isDirectory()) files.push(...listFiles(absolutePath, relativePath));
else if (entry.isFile()) files.push(relativePath);
}
return files.sort((a, b) => a.localeCompare(b));
}
function checkGeneratedFiles(expectedFiles, expectedWrapper) {
const actualFiles = listFiles(outputRoot);
const expectedPaths = [...expectedFiles.keys()].sort((a, b) => a.localeCompare(b));
const problems = [];
if (actualFiles.join("\n") !== expectedPaths.join("\n")) {
problems.push("the vendored declaration file list differs");
}
for (const [relativePath, expected] of expectedFiles) {
const filePath = path.join(outputRoot, relativePath);
if (!isFile(filePath) || fs.readFileSync(filePath, "utf-8") !== expected) {
problems.push(relativePath);
}
}
if (!isFile(wrapperPath) || fs.readFileSync(wrapperPath, "utf-8") !== expectedWrapper) {
problems.push(path.relative(repoRoot, wrapperPath));
}
if (problems.length > 0) {
throw new Error(
`Next.js type snapshot is out of date (${problems.slice(0, 10).join(", ")}). ` +
"Run `pnpm run sync:next-types`.",
);
}
}
function writeGeneratedFiles(files, wrapper) {
fs.rmSync(outputRoot, { recursive: true, force: true });
for (const [relativePath, content] of files) {
const filePath = path.join(outputRoot, relativePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
fs.writeFileSync(wrapperPath, wrapper);
}
function main() {
const args = process.argv.slice(2);
const unknownArgs = args.filter((arg) => arg !== "--check");
if (unknownArgs.length > 0) throw new Error(`Unknown arguments: ${unknownArgs.join(", ")}`);
const files = renderVendoredFiles();
const wrapper = renderWrappers();
if (args.includes("--check")) {
checkGeneratedFiles(files, wrapper);
console.log(
`Next.js types are in sync with next@${nextPackage.version} (${files.size - 1} files)`,
);
} else {
writeGeneratedFiles(files, wrapper);
console.log(`Synced Next.js types from next@${nextPackage.version} (${files.size - 1} files)`);
}
}
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}