465 lines
15 KiB
Python
465 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Analyze Next.js routes and Server Actions to provide optimization recommendations.
|
|
|
|
Usage:
|
|
python analyze_routes.py --path /path/to/app --output analysis-report.md
|
|
python analyze_routes.py --path ./app --format json
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import json
|
|
import argparse
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Set, Tuple
|
|
from dataclasses import dataclass, asdict
|
|
|
|
|
|
@dataclass
|
|
class RouteInfo:
|
|
"""Information about a route or Server Action."""
|
|
path: str
|
|
type: str # 'api_route' or 'server_action'
|
|
methods: List[str] # HTTP methods for API routes
|
|
has_auth: bool
|
|
has_revalidation: bool
|
|
has_external_api: bool
|
|
has_form_data: bool
|
|
has_streaming: bool
|
|
has_cookies: bool
|
|
has_headers: bool
|
|
recommendation: str
|
|
reason: str
|
|
migration_complexity: str # 'low', 'medium', 'high'
|
|
|
|
|
|
class RouteAnalyzer:
|
|
"""Analyze Next.js routes and Server Actions."""
|
|
|
|
def __init__(self, app_path: str):
|
|
self.app_path = Path(app_path)
|
|
self.routes: List[RouteInfo] = []
|
|
|
|
def analyze(self) -> List[RouteInfo]:
|
|
"""Analyze all routes in the app directory."""
|
|
# Find all route.ts/js files (API routes)
|
|
for route_file in self.app_path.rglob('route.*'):
|
|
if route_file.suffix in ['.ts', '.tsx', '.js', '.jsx']:
|
|
self._analyze_api_route(route_file)
|
|
|
|
# Find Server Actions (files with 'use server')
|
|
for file in self.app_path.rglob('*.*'):
|
|
if file.suffix in ['.ts', '.tsx', '.js', '.jsx']:
|
|
if self._has_server_directive(file):
|
|
self._analyze_server_actions(file)
|
|
|
|
return self.routes
|
|
|
|
def _has_server_directive(self, file_path: Path) -> bool:
|
|
"""Check if file has 'use server' directive."""
|
|
try:
|
|
content = file_path.read_text(encoding='utf-8')
|
|
return "'use server'" in content or '"use server"' in content
|
|
except:
|
|
return False
|
|
|
|
def _analyze_api_route(self, file_path: Path):
|
|
"""Analyze an API route file."""
|
|
try:
|
|
content = file_path.read_text(encoding='utf-8')
|
|
except:
|
|
return
|
|
|
|
# Detect HTTP methods
|
|
methods = []
|
|
for method in ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']:
|
|
if re.search(rf'export\s+async\s+function\s+{method}', content):
|
|
methods.append(method)
|
|
|
|
if not methods:
|
|
return
|
|
|
|
# Analyze patterns
|
|
has_auth = self._detect_auth(content)
|
|
has_revalidation = self._detect_revalidation(content)
|
|
has_external_api = self._detect_external_api(content)
|
|
has_form_data = self._detect_form_data(content)
|
|
has_streaming = self._detect_streaming(content)
|
|
has_cookies = self._detect_cookies(content)
|
|
has_headers = self._detect_custom_headers(content)
|
|
|
|
# Generate recommendation
|
|
recommendation, reason, complexity = self._recommend_for_api_route(
|
|
methods=methods,
|
|
has_auth=has_auth,
|
|
has_revalidation=has_revalidation,
|
|
has_external_api=has_external_api,
|
|
has_form_data=has_form_data,
|
|
has_streaming=has_streaming,
|
|
has_cookies=has_cookies,
|
|
has_headers=has_headers,
|
|
)
|
|
|
|
route_info = RouteInfo(
|
|
path=str(file_path.relative_to(self.app_path)),
|
|
type='api_route',
|
|
methods=methods,
|
|
has_auth=has_auth,
|
|
has_revalidation=has_revalidation,
|
|
has_external_api=has_external_api,
|
|
has_form_data=has_form_data,
|
|
has_streaming=has_streaming,
|
|
has_cookies=has_cookies,
|
|
has_headers=has_headers,
|
|
recommendation=recommendation,
|
|
reason=reason,
|
|
migration_complexity=complexity,
|
|
)
|
|
|
|
self.routes.append(route_info)
|
|
|
|
def _analyze_server_actions(self, file_path: Path):
|
|
"""Analyze Server Actions in a file."""
|
|
try:
|
|
content = file_path.read_text(encoding='utf-8')
|
|
except:
|
|
return
|
|
|
|
# Find exported async functions
|
|
function_pattern = re.compile(
|
|
r'export\s+(?:async\s+)?function\s+(\w+)',
|
|
re.MULTILINE
|
|
)
|
|
|
|
for match in function_pattern.finditer(content):
|
|
function_name = match.group(1)
|
|
|
|
# Try to extract function body (basic approach)
|
|
start = match.start()
|
|
# Find the opening brace
|
|
brace_pos = content.find('{', start)
|
|
if brace_pos == -1:
|
|
continue
|
|
|
|
# Simple brace matching (not perfect but works for most cases)
|
|
brace_count = 1
|
|
pos = brace_pos + 1
|
|
while pos < len(content) and brace_count > 0:
|
|
if content[pos] == '{':
|
|
brace_count += 1
|
|
elif content[pos] == '}':
|
|
brace_count -= 1
|
|
pos += 1
|
|
|
|
function_body = content[brace_pos:pos]
|
|
|
|
# Analyze patterns in function body
|
|
has_auth = self._detect_auth(function_body)
|
|
has_revalidation = self._detect_revalidation(function_body)
|
|
has_external_api = self._detect_external_api(function_body)
|
|
has_form_data = 'FormData' in function_body
|
|
|
|
# Generate recommendation
|
|
recommendation, reason, complexity = self._recommend_for_server_action(
|
|
function_name=function_name,
|
|
has_auth=has_auth,
|
|
has_revalidation=has_revalidation,
|
|
has_external_api=has_external_api,
|
|
has_form_data=has_form_data,
|
|
)
|
|
|
|
route_info = RouteInfo(
|
|
path=f"{file_path.relative_to(self.app_path)}::{function_name}",
|
|
type='server_action',
|
|
methods=['POST'], # Server Actions are POST-only
|
|
has_auth=has_auth,
|
|
has_revalidation=has_revalidation,
|
|
has_external_api=has_external_api,
|
|
has_form_data=has_form_data,
|
|
has_streaming=False,
|
|
has_cookies=False,
|
|
has_headers=False,
|
|
recommendation=recommendation,
|
|
reason=reason,
|
|
migration_complexity=complexity,
|
|
)
|
|
|
|
self.routes.append(route_info)
|
|
|
|
def _detect_auth(self, content: str) -> bool:
|
|
"""Detect authentication patterns."""
|
|
patterns = [
|
|
r'auth\(',
|
|
r'getServerSession',
|
|
r'getSession',
|
|
r'session',
|
|
r'currentUser',
|
|
r'verifyToken',
|
|
r'authorization',
|
|
r'Authorization',
|
|
]
|
|
return any(re.search(pattern, content, re.IGNORECASE) for pattern in patterns)
|
|
|
|
def _detect_revalidation(self, content: str) -> bool:
|
|
"""Detect revalidation calls."""
|
|
return 'revalidatePath' in content or 'revalidateTag' in content
|
|
|
|
def _detect_external_api(self, content: str) -> bool:
|
|
"""Detect external API calls."""
|
|
patterns = [
|
|
r'fetch\([\'"]https?://',
|
|
r'axios\.',
|
|
r'\.get\([\'"]https?://',
|
|
r'\.post\([\'"]https?://',
|
|
]
|
|
return any(re.search(pattern, content) for pattern in patterns)
|
|
|
|
def _detect_form_data(self, content: str) -> bool:
|
|
"""Detect FormData handling."""
|
|
return 'FormData' in content or 'formData' in content
|
|
|
|
def _detect_streaming(self, content: str) -> bool:
|
|
"""Detect streaming responses."""
|
|
return 'ReadableStream' in content or 'TransformStream' in content or 'stream' in content.lower()
|
|
|
|
def _detect_cookies(self, content: str) -> bool:
|
|
"""Detect cookie operations."""
|
|
return 'cookies()' in content or 'setCookie' in content
|
|
|
|
def _detect_custom_headers(self, content: str) -> bool:
|
|
"""Detect custom header operations."""
|
|
patterns = [
|
|
r'headers\(\)',
|
|
r'\.headers\.',
|
|
r'setHeader',
|
|
r'Response\.json\([^,]+,\s*\{[^}]*headers',
|
|
]
|
|
return any(re.search(pattern, content) for pattern in patterns)
|
|
|
|
def _recommend_for_api_route(
|
|
self,
|
|
methods: List[str],
|
|
has_auth: bool,
|
|
has_revalidation: bool,
|
|
has_external_api: bool,
|
|
has_form_data: bool,
|
|
has_streaming: bool,
|
|
has_cookies: bool,
|
|
has_headers: bool,
|
|
) -> Tuple[str, str, str]:
|
|
"""Generate recommendation for an API route."""
|
|
reasons = []
|
|
|
|
# Strong indicators to keep as API route
|
|
if has_external_api:
|
|
reasons.append("proxies external API")
|
|
if has_streaming:
|
|
reasons.append("uses streaming responses")
|
|
if has_custom_headers or has_cookies:
|
|
reasons.append("requires custom headers/cookies")
|
|
if 'GET' in methods or len(methods) > 1:
|
|
reasons.append(f"uses multiple HTTP methods ({', '.join(methods)})")
|
|
|
|
# Strong indicators to convert to Server Action
|
|
if methods == ['POST'] and has_revalidation and not has_external_api:
|
|
if has_form_data:
|
|
return (
|
|
'Convert to Server Action',
|
|
'Simple POST with form data and revalidation - ideal for Server Action',
|
|
'low'
|
|
)
|
|
else:
|
|
return (
|
|
'Convert to Server Action',
|
|
'POST-only mutation with revalidation - better as Server Action',
|
|
'low'
|
|
)
|
|
|
|
# Keep as API route
|
|
if reasons:
|
|
return (
|
|
'Keep as API Route',
|
|
'Better as API route: ' + ', '.join(reasons),
|
|
'n/a'
|
|
)
|
|
|
|
# Neutral case - could go either way
|
|
if methods == ['POST']:
|
|
return (
|
|
'Consider Server Action',
|
|
'Simple POST endpoint could be simplified as Server Action',
|
|
'low'
|
|
)
|
|
|
|
return (
|
|
'Keep as API Route',
|
|
'Current implementation is appropriate',
|
|
'n/a'
|
|
)
|
|
|
|
def _recommend_for_server_action(
|
|
self,
|
|
function_name: str,
|
|
has_auth: bool,
|
|
has_revalidation: bool,
|
|
has_external_api: bool,
|
|
has_form_data: bool,
|
|
) -> Tuple[str, str, str]:
|
|
"""Generate recommendation for a Server Action."""
|
|
# Check if Server Action might be better as API route
|
|
if has_external_api and not has_revalidation:
|
|
return (
|
|
'Consider API Route',
|
|
'External API calls without revalidation might be better as API route for reusability',
|
|
'medium'
|
|
)
|
|
|
|
# Good use of Server Action
|
|
if has_revalidation or has_form_data:
|
|
return (
|
|
'Keep as Server Action',
|
|
'Good use of Server Action - leverages revalidation and/or form handling',
|
|
'n/a'
|
|
)
|
|
|
|
# Neutral
|
|
return (
|
|
'Keep as Server Action',
|
|
'Current implementation is appropriate',
|
|
'n/a'
|
|
)
|
|
|
|
def generate_report(self, format: str = 'markdown') -> str:
|
|
"""Generate analysis report."""
|
|
if format == 'json':
|
|
return json.dumps([asdict(route) for route in self.routes], indent=2)
|
|
|
|
# Markdown report
|
|
lines = [
|
|
'# Next.js Route Analysis Report',
|
|
'',
|
|
f'Analyzed {len(self.routes)} routes/actions',
|
|
'',
|
|
]
|
|
|
|
# Summary statistics
|
|
api_routes = [r for r in self.routes if r.type == 'api_route']
|
|
server_actions = [r for r in self.routes if r.type == 'server_action']
|
|
needs_migration = [r for r in self.routes if 'Convert' in r.recommendation or 'Consider' in r.recommendation]
|
|
|
|
lines.extend([
|
|
'## Summary',
|
|
'',
|
|
f'- **API Routes**: {len(api_routes)}',
|
|
f'- **Server Actions**: {len(server_actions)}',
|
|
f'- **Recommended Migrations**: {len(needs_migration)}',
|
|
'',
|
|
])
|
|
|
|
# Recommendations for migration
|
|
if needs_migration:
|
|
lines.extend([
|
|
'## Recommended Migrations',
|
|
'',
|
|
])
|
|
|
|
for route in needs_migration:
|
|
lines.extend([
|
|
f'### {route.path}',
|
|
'',
|
|
f'**Current**: {route.type.replace("_", " ").title()}',
|
|
f'**Recommendation**: {route.recommendation}',
|
|
f'**Reason**: {route.reason}',
|
|
f'**Complexity**: {route.migration_complexity}',
|
|
'',
|
|
])
|
|
|
|
# Detailed analysis
|
|
lines.extend([
|
|
'## Detailed Analysis',
|
|
'',
|
|
])
|
|
|
|
for route in self.routes:
|
|
lines.extend([
|
|
f'### {route.path}',
|
|
'',
|
|
f'- **Type**: {route.type.replace("_", " ").title()}',
|
|
f'- **Methods**: {", ".join(route.methods)}',
|
|
f'- **Authentication**: {"Yes" if route.has_auth else "No"}',
|
|
f'- **Revalidation**: {"Yes" if route.has_revalidation else "No"}',
|
|
f'- **External API**: {"Yes" if route.has_external_api else "No"}',
|
|
f'- **Form Data**: {"Yes" if route.has_form_data else "No"}',
|
|
])
|
|
|
|
if route.type == 'api_route':
|
|
lines.extend([
|
|
f'- **Streaming**: {"Yes" if route.has_streaming else "No"}',
|
|
f'- **Custom Headers/Cookies**: {"Yes" if (route.has_cookies or route.has_headers) else "No"}',
|
|
])
|
|
|
|
lines.extend([
|
|
'',
|
|
f'**Recommendation**: {route.recommendation}',
|
|
f'**Reason**: {route.reason}',
|
|
'',
|
|
])
|
|
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description='Analyze Next.js routes and provide optimization recommendations'
|
|
)
|
|
parser.add_argument(
|
|
'--path',
|
|
required=True,
|
|
help='Path to the Next.js app directory'
|
|
)
|
|
parser.add_argument(
|
|
'--output',
|
|
help='Output file path for the report'
|
|
)
|
|
parser.add_argument(
|
|
'--format',
|
|
choices=['markdown', 'json'],
|
|
default='markdown',
|
|
help='Output format (default: markdown)'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Validate path
|
|
app_path = Path(args.path)
|
|
if not app_path.exists():
|
|
print(f"Error: Path does not exist: {args.path}")
|
|
return 1
|
|
|
|
# Analyze routes
|
|
analyzer = RouteAnalyzer(args.path)
|
|
routes = analyzer.analyze()
|
|
|
|
if not routes:
|
|
print("No routes or Server Actions found.")
|
|
return 0
|
|
|
|
# Generate report
|
|
report = analyzer.generate_report(format=args.format)
|
|
|
|
# Write output
|
|
if args.output:
|
|
output_path = Path(args.output)
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
output_path.write_text(report, encoding='utf-8')
|
|
print(f"Report written to: {args.output}")
|
|
else:
|
|
print(report)
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
exit(main())
|