diff --git a/packages/vinext/src/plugins/ast-utils.ts b/packages/vinext/src/plugins/ast-utils.ts new file mode 100644 index 000000000..901dbf7d9 --- /dev/null +++ b/packages/vinext/src/plugins/ast-utils.ts @@ -0,0 +1,96 @@ +export type AstRecord = { + type: string; + start?: number; + end?: number; + [key: string]: unknown; +}; + +export type AstRange = AstRecord & { + start: number; + end: number; +}; + +const SKIP_CHILD_KEYS = new Set(["type", "parent", "loc", "start", "end"]); + +function getObjectProperty(value: unknown, key: string): unknown { + if (typeof value !== "object" || value === null) return null; + return Reflect.get(value, key); +} + +export function isAstRecord(value: unknown): value is AstRecord { + return typeof getObjectProperty(value, "type") === "string"; +} + +function toAstRecord(value: unknown): AstRecord | null { + return isAstRecord(value) ? value : null; +} + +export function nodeArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +export function hasRange(node: AstRecord | null): node is AstRange { + return node !== null && typeof node.start === "number" && typeof node.end === "number"; +} + +export function isIdentifierNamed(value: unknown, name: string): boolean { + return isAstRecord(value) && value.type === "Identifier" && value.name === name; +} + +export function getAstName(value: unknown): string | null { + const node = toAstRecord(value); + if (!node) return null; + if (node.type === "Identifier" && typeof node.name === "string") return node.name; + if (typeof node.value === "string") return node.value; + return null; +} + +export function forEachAstChild(node: AstRecord, callback: (child: AstRecord) => void): void { + for (const [key, value] of Object.entries(node)) { + if (SKIP_CHILD_KEYS.has(key)) continue; + const child = toAstRecord(value); + if (child) { + callback(child); + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + const itemNode = toAstRecord(item); + if (itemNode) callback(itemNode); + } + } + } +} + +export function collectBindingNames(pattern: unknown, target: Set): void { + const node = toAstRecord(pattern); + if (!node) return; + + switch (node.type) { + case "Identifier": + if (typeof node.name === "string") target.add(node.name); + return; + case "RestElement": + collectBindingNames(node.argument, target); + return; + case "AssignmentPattern": + collectBindingNames(node.left, target); + return; + case "ArrayPattern": + for (const element of nodeArray(node.elements)) collectBindingNames(element, target); + return; + case "ObjectPattern": + for (const property of nodeArray(node.properties)) { + const propertyNode = toAstRecord(property); + if (!propertyNode) continue; + collectBindingNames( + propertyNode.type === "Property" ? propertyNode.value : propertyNode.argument, + target, + ); + } + return; + case "Property": + collectBindingNames(node.value, target); + return; + } +} diff --git a/packages/vinext/src/plugins/import-meta-url.ts b/packages/vinext/src/plugins/import-meta-url.ts index 912c4ea4e..4227ef7b7 100644 --- a/packages/vinext/src/plugins/import-meta-url.ts +++ b/packages/vinext/src/plugins/import-meta-url.ts @@ -16,13 +16,16 @@ import MagicString from "magic-string"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { tryRealpathSync } from "../build/ssr-manifest.js"; - -type NodeLike = { - type?: string; - start?: number; - end?: number; - [key: string]: unknown; -}; +import { + collectBindingNames, + forEachAstChild, + hasRange, + isAstRecord, + isIdentifierNamed, + nodeArray, + type AstRange, + type AstRecord, +} from "./ast-utils.js"; type ImportMetaUrlEnvironment = "client" | "server"; @@ -278,7 +281,7 @@ function collectImportMetaUrlRanges(ast: unknown): Array<{ start: number; end: n const ranges: Array<{ start: number; end: number }> = []; function visit(value: unknown): void { - if (!isNodeLike(value)) return; + if (!isAstRecord(value)) return; if (isImportMetaUrlNode(value)) { ranges.push({ start: value.start, end: value.end }); @@ -301,14 +304,7 @@ function collectImportMetaUrlRanges(ast: unknown): Array<{ start: number; end: n return; } - for (const [key, child] of Object.entries(value)) { - if (key === "type" || key === "start" || key === "end" || key === "loc") continue; - if (Array.isArray(child)) { - for (const item of child) visit(item); - } else { - visit(child); - } - } + forEachAstChild(value, visit); } visit(ast); @@ -357,48 +353,29 @@ function analyzeServerCjsGlobals(ast: unknown): ServerCjsAnalysis { // Recursively walks a binding pattern. Each name found is a module binding. function recordBinding(pattern: unknown): void { - if (!isNodeLike(pattern)) return; - const t = pattern.type; - if (typeof t !== "string") return; - switch (t) { - case "Identifier": - if (isCjsGlobalName(pattern.name)) moduleBindings.add(pattern.name); - return; - case "RestElement": - recordBinding(pattern.argument); - return; - case "AssignmentPattern": - recordBinding(pattern.left); - return; - case "ArrayPattern": - for (const element of nodeArray(pattern.elements)) recordBinding(element); - return; - case "ObjectPattern": - for (const property of nodeArray(pattern.properties)) { - if (!isNodeLike(property)) continue; - recordBinding(property.type === "Property" ? property.value : property.argument); - } - return; + const names = new Set(); + collectBindingNames(pattern, names); + for (const name of names) { + if (isCjsGlobalName(name)) moduleBindings.add(name); } } // Records bindings declared directly by a top-level statement. `var` is // handled by the recursive walk below so nested blocks and loops use the // same rule. - function recordDirectTopLevelBindings(statement: NodeLike): void { + function recordDirectTopLevelBindings(statement: AstRecord): void { const t = statement.type; - if (typeof t !== "string") return; switch (t) { case "ImportDeclaration": for (const specifier of nodeArray(statement.specifiers)) { - if (!isNodeLike(specifier)) continue; + if (!isAstRecord(specifier)) continue; recordBinding(specifier.local); } return; case "VariableDeclaration": if (statement.kind === "var") return; for (const declarator of nodeArray(statement.declarations)) { - if (!isNodeLike(declarator) || declarator.type !== "VariableDeclarator") continue; + if (!isAstRecord(declarator) || declarator.type !== "VariableDeclarator") continue; recordBinding(declarator.id); } return; @@ -408,7 +385,7 @@ function analyzeServerCjsGlobals(ast: unknown): ServerCjsAnalysis { return; case "ExportNamedDeclaration": case "ExportDefaultDeclaration": - if (isNodeLike(statement.declaration)) { + if (isAstRecord(statement.declaration)) { recordDirectTopLevelBindings(statement.declaration); } return; @@ -418,13 +395,12 @@ function analyzeServerCjsGlobals(ast: unknown): ServerCjsAnalysis { // Walk only syntax whose `var` declarations remain module-scoped. Function // and class bodies are scope boundaries. function recordModuleScopedVarBindings(node: unknown): void { - if (!isNodeLike(node)) return; + if (!isAstRecord(node)) return; const t = node.type; - if (typeof t !== "string") return; switch (t) { case "Program": for (const statement of nodeArray(node.body)) { - if (!isNodeLike(statement)) continue; + if (!isAstRecord(statement)) continue; recordDirectTopLevelBindings(statement); recordModuleScopedVarBindings(statement); } @@ -432,7 +408,7 @@ function analyzeServerCjsGlobals(ast: unknown): ServerCjsAnalysis { case "VariableDeclaration": if (node.kind !== "var") return; for (const declarator of nodeArray(node.declarations)) { - if (!isNodeLike(declarator) || declarator.type !== "VariableDeclarator") continue; + if (!isAstRecord(declarator) || declarator.type !== "VariableDeclarator") continue; recordBinding(declarator.id); } return; @@ -449,9 +425,8 @@ function analyzeServerCjsGlobals(ast: unknown): ServerCjsAnalysis { } } - function moduleScopeChildren(node: NodeLike): unknown[] { + function moduleScopeChildren(node: AstRecord): unknown[] { const t = node.type; - if (typeof t !== "string") return []; switch (t) { case "BlockStatement": return nodeArray(node.body); @@ -490,9 +465,8 @@ function analyzeServerCjsGlobals(ast: unknown): ServerCjsAnalysis { // over-report names that are already bound locally, and the module binding // set decides whether injection is safe. function recordReads(value: unknown): void { - if (!isNodeLike(value)) return; + if (!isAstRecord(value)) return; const t = value.type; - if (typeof t !== "string") return; switch (t) { case "Identifier": if (isCjsGlobalName(value.name)) reads.add(value.name); @@ -524,27 +498,20 @@ function analyzeServerCjsGlobals(ast: unknown): ServerCjsAnalysis { // `export { local as exported }` — only `local` references a binding, // and only when there is no `source` (a re-export points at the source // module, not a local). `exported` is always just a name. - if (isNodeLike(value.declaration)) { + if (isAstRecord(value.declaration)) { recordReads(value.declaration); } else if (!value.source) { for (const specifier of nodeArray(value.specifiers)) { - if (isNodeLike(specifier)) recordReads(specifier.local); + if (isAstRecord(specifier)) recordReads(specifier.local); } } return; default: - for (const [key, child] of Object.entries(value)) { - if (key === "type" || key === "start" || key === "end" || key === "loc") continue; - if (Array.isArray(child)) { - for (const item of child) recordReads(item); - } else { - recordReads(child); - } - } + forEachAstChild(value, recordReads); } } - if (isNodeLike(ast) && ast.type === "Program") { + if (isAstRecord(ast) && ast.type === "Program") { recordModuleScopedVarBindings(ast); } recordReads(ast); @@ -552,29 +519,20 @@ function analyzeServerCjsGlobals(ast: unknown): ServerCjsAnalysis { return { reads, moduleBindings }; } -function isNodeLike(value: unknown): value is NodeLike { - return !!value && typeof value === "object" && !Array.isArray(value); -} - -function isIdentifierNamed(value: unknown, name: string): boolean { - return isNodeLike(value) && value.type === "Identifier" && value.name === name; -} - function isImportMetaNode(value: unknown): boolean { return ( - isNodeLike(value) && + isAstRecord(value) && value.type === "MetaProperty" && isIdentifierNamed(value.meta, "import") && isIdentifierNamed(value.property, "meta") ); } -function isImportMetaUrlNode(value: unknown): value is NodeLike & { start: number; end: number } { +function isImportMetaUrlNode(value: unknown): value is AstRange { return ( - isNodeLike(value) && + isAstRecord(value) && value.type === "MemberExpression" && - typeof value.start === "number" && - typeof value.end === "number" && + hasRange(value) && isImportMetaNode(value.object) && isIdentifierNamed(value.property, "url") ); @@ -583,53 +541,50 @@ function isImportMetaUrlNode(value: unknown): value is NodeLike & { start: numbe // Accepts both import.meta.url (MemberExpression) and import.meta?.url // (ChainExpression wrapping a MemberExpression) so that the new URL() skip // correctly handles optional-chained base arguments. -function isImportMetaUrlOrChainedNode( - value: unknown, -): value is NodeLike & { start: number; end: number } { +function isImportMetaUrlOrChainedNode(value: unknown): value is AstRange { if (isImportMetaUrlNode(value)) return true; return ( - isNodeLike(value) && value.type === "ChainExpression" && isImportMetaUrlNode(value.expression) + isAstRecord(value) && value.type === "ChainExpression" && isImportMetaUrlNode(value.expression) ); } // Catches the ChainExpression wrapper so we record the outer node range // and avoid descending into the inner MemberExpression (which happens // to share the same start/end, but this is more explicit). -function isChainExpressionWrappingImportMetaUrl( - value: unknown, -): value is NodeLike & { start: number; end: number } { +function isChainExpressionWrappingImportMetaUrl(value: unknown): value is AstRange { return ( - isNodeLike(value) && + isAstRecord(value) && value.type === "ChainExpression" && - typeof value.start === "number" && - typeof value.end === "number" && + hasRange(value) && isImportMetaUrlNode(value.expression) ); } // Only matches bare `new URL(...)`, not `new globalThis.URL(...)` or // `new window.URL(...)`. Matches Vite's own asset-detection scope. -function isNewUrlExpression(value: NodeLike): boolean { +function isNewUrlExpression(value: AstRecord): boolean { return value.type === "NewExpression" && isIdentifierNamed(value.callee, "URL"); } function findDirectivePrologueEnd(ast: unknown): number { - if (!isNodeLike(ast) || ast.type !== "Program") return 0; + if (!isAstRecord(ast) || ast.type !== "Program") return 0; // A shebang (`#!...`) lives outside ast.body but must stay the first bytes of // the file, so the injection floor starts after it. Inserting at offset 0 // would move the shebang off line 1 and produce invalid output. let end = 0; const hashbang = ast.hashbang; - if (isNodeLike(hashbang) && typeof hashbang.end === "number") { - end = hashbang.end; + const hashbangEnd = + typeof hashbang === "object" && hashbang !== null ? Reflect.get(hashbang, "end") : null; + if (typeof hashbangEnd === "number") { + end = hashbangEnd; } for (const statement of nodeArray(ast.body)) { if ( - !isNodeLike(statement) || + !isAstRecord(statement) || statement.type !== "ExpressionStatement" || - !isNodeLike(statement.expression) || + !isAstRecord(statement.expression) || statement.expression.type !== "Literal" || typeof statement.expression.value !== "string" || typeof statement.end !== "number" @@ -641,7 +596,3 @@ function findDirectivePrologueEnd(ast: unknown): number { return end; } - -function nodeArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} diff --git a/packages/vinext/src/plugins/optimize-imports.ts b/packages/vinext/src/plugins/optimize-imports.ts index c6f9cd701..ed672bed9 100644 --- a/packages/vinext/src/plugins/optimize-imports.ts +++ b/packages/vinext/src/plugins/optimize-imports.ts @@ -18,6 +18,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import MagicString from "magic-string"; import type { ResolvedNextConfig } from "../config/next-config.js"; +import { getAstName } from "./ast-utils.js"; /** * Read a file's contents, returning null on any error. @@ -31,14 +32,6 @@ async function readFileSafe(filepath: string): Promise { } } -/** Extract the string name from an Identifier ({name}) or Literal ({value}) AST node. - * Returns null for unexpected node shapes so callers can degrade gracefully rather than crash. */ -function astName(node: { name?: string; value?: string | boolean | number | null }): string | null { - if (node.name !== undefined) return node.name; - if (typeof node.value === "string") return node.value; - return null; -} - /** Nested conditional exports value (string path or nested conditions). */ type ExportsValue = string | { [condition: string]: ExportsValue }; @@ -408,7 +401,7 @@ async function buildExportMapFromFile( break; case "ImportSpecifier": if (spec.imported) { - const name = astName(spec.imported); + const name = getAstName(spec.imported); if (name !== null) { importBindings.set(spec.local.name, { source, @@ -448,7 +441,7 @@ async function buildExportMapFromFile( if (node.exported) { // export * as Name from "sub-pkg" — namespace re-export - const name = astName(node.exported); + const name = getAstName(node.exported); if (name !== null) { exportMap.set(name, { source: normalizeSource(rawSource), isNamespace: true }); } @@ -514,8 +507,8 @@ async function buildExportMapFromFile( // export { A, B } from "sub-pkg" for (const spec of node.specifiers ?? []) { if (spec.exported) { - const exported = astName(spec.exported); - const local = astName(spec.local); + const exported = getAstName(spec.exported); + const local = getAstName(spec.local); if (exported !== null) { exportMap.set(exported, { source, @@ -529,8 +522,8 @@ async function buildExportMapFromFile( // export { X } — look up X in importBindings for (const spec of node.specifiers) { if (!spec.exported) continue; - const exported = astName(spec.exported); - const local = astName(spec.local); + const exported = getAstName(spec.exported); + const local = getAstName(spec.local); if (exported === null || local === null) continue; const binding = importBindings.get(local); if (binding) { @@ -825,7 +818,7 @@ export function createOptimizeImportsPlugin( allResolved = false; break; } - const imported = astName(spec.imported); + const imported = getAstName(spec.imported); if (imported === null) { // Malformed AST node — degrade gracefully by skipping the import allResolved = false; @@ -856,7 +849,7 @@ export function createOptimizeImportsPlugin( if (allResolved === false) { for (const spec of node.specifiers ?? []) { if (spec.type === "ImportSpecifier" && spec.imported) { - const imported = astName(spec.imported); + const imported = getAstName(spec.imported); if (imported !== null && !exportMap.has(imported)) { console.debug( `[vinext:optimize-imports] skipping "${importSource}": could not resolve specifier "${imported}" in barrel export map`,