UNPKG

one

Version:

One is a new React Framework that makes Vite serve both native and web.

827 lines (762 loc) 25.8 kB
import { extname, relative } from 'node:path' import MagicString from 'magic-string' import { parseSync } from 'oxc-parser' import type { Plugin } from 'vite' import { EMPTY_LOADER_STRING, makeLoaderRouteIdStub } from '../constants' export const clientTreeShakePlugin = (opts?: { // 'rolldown' when used in the native rolldown DevEngine (no vite environment context) runtime?: 'vite' | 'rolldown' // the configured router root (One.PluginOptions.router.root), relative to the // project root. the loader stub's routeId is built off it, so it must be the // real one or buildPage cannot match the stub back to the route. routerRoot?: string }): Plugin => { const runtime = opts?.runtime ?? 'vite' const routerRoot = opts?.routerRoot ?? 'app' return { name: 'one-client-tree-shake', enforce: 'pre', ...(runtime === 'vite' && { applyToEnvironment(env: { name: string }) { return env.name === 'client' || env.name === 'ios' || env.name === 'android' }, }), transform: { order: 'pre', async handler(code, id, settings) { if (runtime === 'vite' && this.environment?.name === 'ssr') { return } if (!/\.(js|jsx|ts|tsx)/.test(extname(id))) { return } if (/node_modules/.test(id)) { return } const out = await transformTreeShakeClient(code, id, process.cwd(), routerRoot) return out }, }, } satisfies Plugin } function extractPatternBindings(node: any, names: Set<string>): void { if (!node) return switch (node.type) { case 'Identifier': names.add(node.name) break case 'ObjectPattern': for (const prop of node.properties) { if (prop.type === 'Property') { extractPatternBindings(prop.value, names) } else if (prop.type === 'RestElement') { extractPatternBindings(prop.argument, names) } } break case 'ArrayPattern': for (const element of node.elements) { if (element) { extractPatternBindings(element, names) } } break case 'AssignmentPattern': extractPatternBindings(node.left, names) break case 'RestElement': extractPatternBindings(node.argument, names) break case 'TSParameterProperty': extractPatternBindings(node.parameter, names) break } } function getReferencedIdentifiers( rootNode: any, candidateNames: Set<string> ): Set<string> { const referenced = new Set<string>() const scopeStack: Set<string>[] = [] function isShadowed(name: string): boolean { for (let i = scopeStack.length - 1; i >= 0; i--) { if (scopeStack[i]!.has(name)) return true } return false } function checkIdentifier(name: string): void { if (!isShadowed(name) && candidateNames.has(name)) { referenced.add(name) } } function walk(node: any): void { if (!node || typeof node !== 'object') return // Skip TypeScript type declarations and annotations if ( node.type === 'TSTypeAnnotation' || node.type === 'TSTypeReference' || node.type === 'TSTypeAliasDeclaration' || node.type === 'TSInterfaceDeclaration' || node.type === 'TSTypeParameterDeclaration' || node.type === 'TSTypeParameterInstantiation' ) { return } if ( node.type === 'TSAsExpression' || node.type === 'TSTypeAssertion' || node.type === 'TSSatisfiesExpression' || node.type === 'TSNonNullExpression' ) { walk(node.expression) return } function walkPatternExpressions(pat: any): void { if (!pat || typeof pat !== 'object') return switch (pat.type) { case 'AssignmentPattern': walk(pat.right) walkPatternExpressions(pat.left) break case 'ObjectPattern': for (const prop of pat.properties) { if (prop.type === 'Property') { if (prop.computed) { walk(prop.key) } walkPatternExpressions(prop.value) } else if (prop.type === 'RestElement') { walkPatternExpressions(prop.argument) } } break case 'ArrayPattern': for (const element of pat.elements) { if (element) { walkPatternExpressions(element) } } break case 'RestElement': walkPatternExpressions(pat.argument) break case 'TSParameterProperty': walkPatternExpressions(pat.parameter) break } } // Function scopes if ( node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression' ) { const scope = new Set<string>() if (node.type === 'FunctionExpression' && node.id) { scope.add(node.id.name) } if (node.params) { for (const param of node.params) { walkPatternExpressions(param) extractPatternBindings(param, scope) } } // Add var declarations hoisted in function function addVars(n: any): void { if (!n || typeof n !== 'object') return if ( n.type === 'FunctionDeclaration' || n.type === 'FunctionExpression' || n.type === 'ArrowFunctionExpression' ) { return // do not cross into inner functions } if (n.type === 'VariableDeclaration' && n.kind === 'var') { for (const d of n.declarations) { extractPatternBindings(d.id, scope) } } for (const k of Object.keys(n)) { if (k === 'parent') continue const val = n[k] if (Array.isArray(val)) { for (const item of val) addVars(item) } else if (val && typeof val === 'object') { addVars(val) } } } addVars(node.body) scopeStack.push(scope) walk(node.body) scopeStack.pop() return } // Block scopes if (node.type === 'BlockStatement') { const scope = new Set<string>() for (const stmt of node.body) { if ( stmt.type === 'VariableDeclaration' && (stmt.kind === 'let' || stmt.kind === 'const') ) { for (const decl of stmt.declarations) { extractPatternBindings(decl.id, scope) } } else if (stmt.type === 'FunctionDeclaration' && stmt.id) { scope.add(stmt.id.name) } else if (stmt.type === 'ClassDeclaration' && stmt.id) { scope.add(stmt.id.name) } } scopeStack.push(scope) for (const stmt of node.body) { walk(stmt) } scopeStack.pop() return } // CatchClause if (node.type === 'CatchClause') { const scope = new Set<string>() if (node.param) { extractPatternBindings(node.param, scope) } scopeStack.push(scope) walk(node.body) scopeStack.pop() return } // Loop scopes if ( node.type === 'ForStatement' || node.type === 'ForInStatement' || node.type === 'ForOfStatement' ) { const scope = new Set<string>() const initOrLeft = node.init || node.left if ( initOrLeft && initOrLeft.type === 'VariableDeclaration' && initOrLeft.kind !== 'var' ) { for (const decl of initOrLeft.declarations) { extractPatternBindings(decl.id, scope) } } scopeStack.push(scope) if (node.init) walk(node.init) if (node.left) walk(node.left) if (node.right) walk(node.right) if (node.test) walk(node.test) if (node.update) walk(node.update) walk(node.body) scopeStack.pop() return } // Identifiers if (node.type === 'Identifier') { checkIdentifier(node.name) return } // JSX if (node.type === 'JSXOpeningElement') { if (node.name) { if (node.name.type === 'JSXIdentifier') { if (node.name.name && /^[A-Z]/.test(node.name.name)) { checkIdentifier(node.name.name) } } else if (node.name.type === 'JSXMemberExpression') { let rootObj = node.name.object while (rootObj && rootObj.type === 'JSXMemberExpression') { rootObj = rootObj.object } if (rootObj && rootObj.type === 'JSXIdentifier') { checkIdentifier(rootObj.name) } } } if (node.attributes) { for (const attr of node.attributes) { walk(attr) } } return } if (node.type === 'JSXAttribute') { if (node.value) walk(node.value) return } if (node.type === 'JSXClosingElement') { return } // Property in ObjectExpression if (node.type === 'Property') { if (node.computed) { walk(node.key) } walk(node.value) return } // MemberExpression if (node.type === 'MemberExpression') { walk(node.object) if (node.computed) { walk(node.property) } return } // VariableDeclarator if (node.type === 'VariableDeclarator') { walkPatternExpressions(node.id) if (node.init) { walk(node.init) } return } // ExportSpecifier if (node.type === 'ExportSpecifier') { if (node.local && node.local.type === 'Identifier') { checkIdentifier(node.local.name) } return } // Generic recursive walk for (const key of Object.keys(node)) { if (key === 'parent') continue const child = node[key] if (Array.isArray(child)) { for (const item of child) { if (item && typeof item === 'object') walk(item) } } else if (child && typeof child === 'object') { walk(child) } } } walk(rootNode) return referenced } export async function transformTreeShakeClient( code: string, id: string, root?: string, routerRoot = 'app' ) { if (!/generateStaticParams|loader/.test(code)) { return } const isProd = process.env.NODE_ENV === 'production' const filename = (id || 'file.tsx').split('?')[0]!.split('#')[0]! let parseResult: any try { parseResult = parseSync(filename, code) if (parseResult.errors && parseResult.errors.length > 0) { const errorMessage = parseResult.errors.map((e: any) => e.message).join('\n') if (isProd) { throw new Error( `[one] Failed to parse ${id} with server exports:\n${errorMessage}` ) } console.warn( `[one] Skipping tree shaking for ${id} due to syntax error:`, errorMessage ) return } } catch (error) { if (isProd) { throw error instanceof Error ? error : new Error(String(error)) } const errorMessage = error instanceof Error ? error.message : String(error) console.warn( `[one] Skipping tree shaking for ${id} due to syntax error:`, errorMessage ) return } const ast = parseResult.program try { return doTreeShakeClient(code, id, root, ast, routerRoot) } catch (error) { if (isProd) { throw error instanceof Error ? error : new Error(String(error)) } const errorMessage = error instanceof Error ? error.message : String(error) console.warn( `[one] Skipping tree shaking for ${id} due to analysis error:`, errorMessage ) return } } function doTreeShakeClient( code: string, id: string, root: string | undefined, ast: any, routerRoot: string ) { const removed: Record<string, boolean> = { loader: false, generateStaticParams: false, } const serverExportStmts = new Map< any, { names: Set<string>; replaceAll: boolean; declaratorsToRemove: Set<any> } >() for (const stmt of ast.body) { if (stmt.type === 'ExportNamedDeclaration' && stmt.declaration) { if (stmt.declaration.type === 'FunctionDeclaration') { const fnName = stmt.declaration.id?.name if (fnName === 'loader' || fnName === 'generateStaticParams') { removed[fnName] = true serverExportStmts.set(stmt, { names: new Set([fnName]), replaceAll: true, declaratorsToRemove: new Set(), }) } } else if (stmt.declaration.type === 'VariableDeclaration') { const found: any[] = [] for (const d of stmt.declaration.declarations) { if ( d.id?.type === 'Identifier' && (d.id.name === 'loader' || d.id.name === 'generateStaticParams') ) { found.push(d) removed[d.id.name] = true } } if (found.length > 0) { serverExportStmts.set(stmt, { names: new Set(found.map((d) => d.id.name)), replaceAll: found.length === stmt.declaration.declarations.length, declaratorsToRemove: new Set(found), }) } } } } const removedFunctions = Object.keys(removed).filter((k) => removed[k]) if (removedFunctions.length === 0) { return } // Collect all module bindings const moduleBindings = new Map< string, { kind: 'import' | 'function' | 'variable' | 'class' | 'enum' stmt: any node: any isType?: boolean } >() for (const stmt of ast.body) { if (stmt.type === 'ImportDeclaration') { const isDeclType = stmt.importKind === 'type' for (const sp of stmt.specifiers) { const isType = isDeclType || sp.importKind === 'type' moduleBindings.set(sp.local.name, { kind: 'import', stmt, node: sp, isType, }) } } else if (stmt.type === 'ExportNamedDeclaration' && stmt.declaration) { const decl = stmt.declaration if (decl.type === 'FunctionDeclaration' && decl.id) { moduleBindings.set(decl.id.name, { kind: 'function', stmt, node: decl }) } else if (decl.type === 'VariableDeclaration') { for (const d of decl.declarations) { const names = new Set<string>() extractPatternBindings(d.id, names) for (const name of names) { moduleBindings.set(name, { kind: 'variable', stmt, node: d }) } } } else if (decl.type === 'ClassDeclaration' && decl.id) { moduleBindings.set(decl.id.name, { kind: 'class', stmt, node: decl }) } } else if (stmt.type === 'FunctionDeclaration' && stmt.id) { moduleBindings.set(stmt.id.name, { kind: 'function', stmt, node: stmt }) } else if (stmt.type === 'VariableDeclaration') { for (const d of stmt.declarations) { const names = new Set<string>() extractPatternBindings(d.id, names) for (const name of names) { moduleBindings.set(name, { kind: 'variable', stmt, node: d }) } } } else if (stmt.type === 'ClassDeclaration' && stmt.id) { moduleBindings.set(stmt.id.name, { kind: 'class', stmt, node: stmt }) } else if (stmt.type === 'TSEnumDeclaration' && stmt.id) { moduleBindings.set(stmt.id.name, { kind: 'enum', stmt, node: stmt }) } } const allBindingNames = new Set(moduleBindings.keys()) const topLevelDeps = new Map<string, Set<string>>() const clientRootDeps = new Set<string>() // Compute dependencies of all module declarations for (const [name, info] of moduleBindings) { if (info.kind === 'import') { topLevelDeps.set(name, new Set()) } else if (info.kind === 'function') { topLevelDeps.set(name, getReferencedIdentifiers(info.node, allBindingNames)) } else if (info.kind === 'variable') { const deps = getReferencedIdentifiers(info.node, allBindingNames) deps.delete(name) topLevelDeps.set(name, deps) } else if (info.kind === 'class') { topLevelDeps.set(name, getReferencedIdentifiers(info.node, allBindingNames)) } else if (info.kind === 'enum') { topLevelDeps.set(name, getReferencedIdentifiers(info.node, allBindingNames)) } } // 1. BFS for server needed (transitive closure of loader and generateStaticParams) const serverNeeded = new Set<string>() const serverQueue: string[] = [] for (const fn of removedFunctions) { const deps = topLevelDeps.get(fn) if (deps) { for (const dep of deps) serverQueue.push(dep) } } while (serverQueue.length > 0) { const name = serverQueue.shift()! if (serverNeeded.has(name)) continue serverNeeded.add(name) const deps = topLevelDeps.get(name) if (deps) { for (const dep of deps) { if (!serverNeeded.has(dep)) serverQueue.push(dep) } } } // 2. Compute client root dependencies from all retained statements for (const stmt of ast.body) { if (stmt.type === 'ImportDeclaration') { continue } if (serverExportStmts.has(stmt)) { const info = serverExportStmts.get(stmt)! if (!info.replaceAll && stmt.declaration?.declarations) { for (const d of stmt.declaration.declarations) { if (!info.declaratorsToRemove.has(d)) { const deps = getReferencedIdentifiers(d, allBindingNames) for (const dep of deps) clientRootDeps.add(dep) } } } continue } if (stmt.type === 'VariableDeclaration') { const allInServer = stmt.declarations.every((d: any) => { const names = new Set<string>() extractPatternBindings(d.id, names) return names.size > 0 && Array.from(names).every((n) => serverNeeded.has(n)) }) if (!allInServer) { // Retained on client! for (const d of stmt.declarations) { const names = new Set<string>() extractPatternBindings(d.id, names) const inServer = names.size > 0 && Array.from(names).every((n) => serverNeeded.has(n)) if (!inServer) { const deps = getReferencedIdentifiers(d, allBindingNames) for (const dep of deps) clientRootDeps.add(dep) } } } continue } if ( (stmt.type === 'FunctionDeclaration' || stmt.type === 'ClassDeclaration' || stmt.type === 'TSEnumDeclaration') && stmt.id && serverNeeded.has(stmt.id.name) ) { // Local server declaration, do not treat as client root continue } // All other top-level statements (ExportDefaultDeclaration, ExportNamedDeclaration, // ExpressionStatement, IfStatement, ForStatement, WhileStatement, TryStatement, BlockStatement, etc.) const deps = getReferencedIdentifiers(stmt, allBindingNames) for (const dep of deps) clientRootDeps.add(dep) } // 3. BFS for client needed const clientNeeded = new Set<string>() const clientQueue = Array.from(clientRootDeps) while (clientQueue.length > 0) { const name = clientQueue.shift()! if (clientNeeded.has(name)) continue clientNeeded.add(name) // Do NOT expand server stub exports into server dependencies if (name === 'loader' || name === 'generateStaticParams') continue const deps = topLevelDeps.get(name) if (deps) { for (const dep of deps) { if (!clientNeeded.has(dep)) clientQueue.push(dep) } } } // 4. Exclusive server bindings const exclusiveServerBindings = new Set<string>() for (const name of serverNeeded) { if (!clientNeeded.has(name)) { exclusiveServerBindings.add(name) } } // Use MagicString to apply modifications const s = new MagicString(code) // 1. Replace server export declarations with stubs for (const [stmt, info] of serverExportStmts) { const stubs: string[] = [] if (info.names.has('loader')) { if (root) { // compute routeId relative to the router root so it matches the // route contextKey format buildPage looks the stub up by. // contextKeys are like "./_layout.tsx", "./matches-test/page1+ssg.tsx" const fromRoot = relative(root, id).replace(/\\/g, '/') const prefix = routerRoot.replace(/^\.\//, '').replace(/\/$/, '') + '/' const routeId = './' + (fromRoot.startsWith(prefix) ? fromRoot.slice(prefix.length) : fromRoot) stubs.push(makeLoaderRouteIdStub(routeId)) } else { stubs.push(EMPTY_LOADER_STRING) } } if (info.names.has('generateStaticParams')) { stubs.push('export function generateStaticParams() {};') } if (info.replaceAll) { s.overwrite(stmt.start, stmt.end, stubs.join('\n')) } else { // Remove only the server declarators const decl = stmt.declaration for (let i = decl.declarations.length - 1; i >= 0; i--) { const d = decl.declarations[i] if (info.declaratorsToRemove.has(d)) { if (i === 0) { const next = decl.declarations[1] s.remove(d.start, next.start) } else { const prev = decl.declarations[i - 1] s.remove(prev.end, d.end) } } } s.appendRight(stmt.end, '\n' + stubs.join('\n')) } } // 2. Remove exclusive local declarations (functions, variables, classes) for (const stmt of ast.body) { if (serverExportStmts.has(stmt) || stmt.type === 'ImportDeclaration') continue if ( (stmt.type === 'FunctionDeclaration' || stmt.type === 'ClassDeclaration' || stmt.type === 'TSEnumDeclaration') && stmt.id && exclusiveServerBindings.has(stmt.id.name) ) { let end = stmt.end if (code[end] === ';') end++ if (code[end] === '\r' && code[end + 1] === '\n') end += 2 else if (code[end] === '\n') end += 1 s.remove(stmt.start, end) } else if (stmt.type === 'VariableDeclaration') { const allExclusive = stmt.declarations.every((d: any) => { const names = new Set<string>() extractPatternBindings(d.id, names) return Array.from(names).every((n) => exclusiveServerBindings.has(n)) }) if (allExclusive) { let end = stmt.end if (code[end] === ';') end++ if (code[end] === '\r' && code[end + 1] === '\n') end += 2 else if (code[end] === '\n') end += 1 s.remove(stmt.start, end) } else { // Check if any individual declarators should be removed for (let i = stmt.declarations.length - 1; i >= 0; i--) { const d = stmt.declarations[i] const names = new Set<string>() extractPatternBindings(d.id, names) if (Array.from(names).every((n) => exclusiveServerBindings.has(n))) { if (i === 0) { const next = stmt.declarations[1] s.remove(d.start, next.start) } else { const prev = stmt.declarations[i - 1] s.remove(prev.end, d.end) } } } } } else if ( stmt.type === 'ClassDeclaration' && stmt.id && exclusiveServerBindings.has(stmt.id.name) ) { let end = stmt.end if (code[end] === ';') end++ if (code[end] === '\r' && code[end + 1] === '\n') end += 2 else if (code[end] === '\n') end += 1 s.remove(stmt.start, end) } } // 3. Remove unused import specifiers / declarations for (const stmt of ast.body) { if (stmt.type !== 'ImportDeclaration') continue if (stmt.importKind === 'type') continue // Preserve type-only imports if (!stmt.specifiers || stmt.specifiers.length === 0) continue // Preserve side-effect imports const kept = stmt.specifiers.filter((sp: any) => { if (sp.importKind === 'type') return true return !exclusiveServerBindings.has(sp.local.name) }) if (kept.length === 0) { let end = stmt.end if (code[end] === ';') end++ if (code[end] === '\r' && code[end + 1] === '\n') end += 2 else if (code[end] === '\n') end += 1 s.remove(stmt.start, end) } else if (kept.length < stmt.specifiers.length) { const declCode = code.slice(stmt.start, stmt.source.start) const prefixMatch = declCode.match(/^import\s+/) const prefixEnd = stmt.start + (prefixMatch ? prefixMatch[0].length : 7) let pos = stmt.source.start - 1 while (pos >= 0 && /\s/.test(code[pos]!)) pos-- const fromStart = pos - 3 // 'from' keyword start const defaultSpec = kept.find((sp: any) => sp.type === 'ImportDefaultSpecifier') const namespaceSpec = kept.find((sp: any) => sp.type === 'ImportNamespaceSpecifier') const namedSpecs = kept.filter((sp: any) => sp.type === 'ImportSpecifier') const parts: string[] = [] if (defaultSpec) parts.push(defaultSpec.local.name) if (namespaceSpec) parts.push('* as ' + namespaceSpec.local.name) if (namedSpecs.length > 0) { const namedStr = namedSpecs .map((sp: any) => { const typePrefix = sp.importKind === 'type' ? 'type ' : '' const importedName = sp.imported.raw || sp.imported.name if (importedName === sp.local.name) { return typePrefix + sp.local.name } return typePrefix + importedName + ' as ' + sp.local.name }) .join(', ') parts.push('{ ' + namedStr + ' }') } s.overwrite(prefixEnd, fromStart, parts.join(', ') + ' ') } } console.info( ` 🧹 [one] ${relative(process.cwd(), id)} removed ${removedFunctions.length} server-only exports` ) return { code: s.toString(), map: s.generateMap({ hires: 'boundary', source: id, includeContent: true }), } }