diff --git a/.env.example b/.env.example index 55d391f3..6dc36975 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,10 @@ NEXT_PUBLIC_FEATURE_OFFLINE_MODE=true NEXT_PUBLIC_FEATURE_PERFORMANCE_ANALYTICS=true NEXT_PUBLIC_FEATURE_DAO_GOVERNANCE=false NEXT_PUBLIC_FEATURE_COLLABORATIVE_EDITING=false + +# Edge Deployment (#276) +EDGE_REGION=auto +EDGE_CACHE_TTL=60 +EDGE_LOG_LEVEL=info +EDGE_ENABLE_LOGGING=true +EDGE_TIMEOUT_MS=5000 diff --git a/infra/edge-config.ts b/infra/edge-config.ts new file mode 100644 index 00000000..4a8bd4a1 --- /dev/null +++ b/infra/edge-config.ts @@ -0,0 +1,59 @@ +// ── Edge Deployment Configuration (#276) ───────────────────────────────────── +// Centralizes edge runtime env vars, CDN caching strategy, and cold-start +// optimization settings for Vercel Edge / Cloudflare Workers deployments. + +// ── Environment variables ───────────────────────────────────────────────────── +export const EDGE_REGION = process.env.EDGE_REGION ?? 'auto'; +export const EDGE_CACHE_TTL = parseInt(process.env.EDGE_CACHE_TTL ?? '60', 10); +export const EDGE_LOG_LEVEL = process.env.EDGE_LOG_LEVEL ?? 'info'; +export const EDGE_ENABLE_LOGGING = + process.env.EDGE_ENABLE_LOGGING !== 'false'; + +// ── CDN caching strategy ────────────────────────────────────────────────────── +// s-maxage controls edge/CDN cache lifetime; stale-while-revalidate allows +// serving stale content while the edge node revalidates in the background. +export const CDN_CACHE_HEADERS = { + // Public read routes: cache at the edge for EDGE_CACHE_TTL seconds + public: `public, s-maxage=${EDGE_CACHE_TTL}, stale-while-revalidate=30`, + // Mutation / auth routes: must not be cached at the edge + private: 'private, no-store, no-cache', +} as const; + +// ── Cold-start optimization ─────────────────────────────────────────────────── +// Keep module-level state minimal. Heavy initialisation should be deferred +// inside request handlers so the edge runtime can load the module quickly. +export const COLD_START_CONFIG = { + // Maximum response time (ms) before the edge function is considered unhealthy + timeoutMs: parseInt(process.env.EDGE_TIMEOUT_MS ?? '5000', 10), + // Lightweight keep-alive ping path used by the platform health checker + keepAlivePath: '/api/health', +} as const; + +// ── Edge-specific logger ────────────────────────────────────────────────────── +// Uses console.* so logs surface in Vercel / Cloudflare log drains without +// any additional dependency. +export function edgeLog( + level: 'info' | 'warn' | 'error', + route: string, + message: string, + meta?: Record, +): void { + if (!EDGE_ENABLE_LOGGING) return; + + const entry = { + level, + route, + message, + region: EDGE_REGION, + ts: new Date().toISOString(), + ...meta, + }; + + if (level === 'error') { + console.error('[edge]', JSON.stringify(entry)); + } else if (level === 'warn') { + console.warn('[edge]', JSON.stringify(entry)); + } else { + console.log('[edge]', JSON.stringify(entry)); + } +} diff --git a/src/app/api/admin/feature-flags/[id]/route.ts b/src/app/api/admin/feature-flags/[id]/route.ts index a88e9c00..b5f78970 100644 --- a/src/app/api/admin/feature-flags/[id]/route.ts +++ b/src/app/api/admin/feature-flags/[id]/route.ts @@ -2,10 +2,14 @@ import { NextRequest, NextResponse } from 'next/server'; import { flagStore, createAuditEntry } from '@/lib/feature-flags/store'; import type { FeatureFlag, TargetingRule } from '@/lib/feature-flags/store'; import { withRateLimit } from '@/lib/ratelimit'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; // ─── GET /api/admin/feature-flags/[id] ─────────────────────────────────────── export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + edgeLog('info', '/api/admin/feature-flags/[id]', 'GET request received'); const { addHeaders, rateLimitResponse } = withRateLimit(req, 'READ'); if (rateLimitResponse) return rateLimitResponse; @@ -20,6 +24,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: // Full or partial update. Also handles toggle via { enabled: boolean }. export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + edgeLog('info', '/api/admin/feature-flags/[id]', 'PUT request received'); const { addHeaders, rateLimitResponse } = withRateLimit(req, 'AUTH'); if (rateLimitResponse) return rateLimitResponse; @@ -60,6 +65,7 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: // ─── DELETE /api/admin/feature-flags/[id] ──────────────────────────────────── export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + edgeLog('info', '/api/admin/feature-flags/[id]', 'DELETE request received'); const { addHeaders, rateLimitResponse } = withRateLimit(req, 'AUTH'); if (rateLimitResponse) return rateLimitResponse; diff --git a/src/app/api/admin/feature-flags/audit/route.ts b/src/app/api/admin/feature-flags/audit/route.ts index f52a5106..9166a46d 100644 --- a/src/app/api/admin/feature-flags/audit/route.ts +++ b/src/app/api/admin/feature-flags/audit/route.ts @@ -1,11 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { auditLog } from '@/lib/feature-flags/store'; import { withRateLimit } from '@/lib/ratelimit'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; /** * GET /api/admin/feature-flags/audit?flagId=&limit=50&offset=0 */ export async function GET(req: NextRequest) { + edgeLog('info', '/api/admin/feature-flags/audit', 'GET request received'); const { addHeaders, rateLimitResponse } = withRateLimit(req, 'READ'); if (rateLimitResponse) return rateLimitResponse; diff --git a/src/app/api/admin/feature-flags/evaluate/route.ts b/src/app/api/admin/feature-flags/evaluate/route.ts index 7085f64c..2d8d7e41 100644 --- a/src/app/api/admin/feature-flags/evaluate/route.ts +++ b/src/app/api/admin/feature-flags/evaluate/route.ts @@ -1,6 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { flagStore, evaluateFlag } from '@/lib/feature-flags/store'; import { withRateLimit } from '@/lib/ratelimit'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; /** * GET /api/admin/feature-flags/evaluate?id=&userId=&plan=… @@ -8,6 +11,7 @@ import { withRateLimit } from '@/lib/ratelimit'; * All query params beyond `id` are passed as the evaluation context. */ export async function GET(req: NextRequest) { + edgeLog('info', '/api/admin/feature-flags/evaluate', 'GET request received'); const { addHeaders, rateLimitResponse } = withRateLimit(req, 'READ'); if (rateLimitResponse) return rateLimitResponse; diff --git a/src/app/api/admin/feature-flags/route.ts b/src/app/api/admin/feature-flags/route.ts index 455ae1c3..617e3e0a 100644 --- a/src/app/api/admin/feature-flags/route.ts +++ b/src/app/api/admin/feature-flags/route.ts @@ -8,11 +8,15 @@ import { } from '@/lib/feature-flags/store'; import type { FeatureFlag, TargetingRule } from '@/lib/feature-flags/store'; import { withRateLimit } from '@/lib/ratelimit'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; // ─── GET /api/admin/feature-flags ───────────────────────────────────────────── // Returns the full flag list sorted by updatedAt desc. export async function GET(req: NextRequest) { + edgeLog('info', '/api/admin/feature-flags', 'GET request received'); const { addHeaders, rateLimitResponse } = withRateLimit(req, 'READ'); if (rateLimitResponse) return rateLimitResponse; @@ -27,7 +31,8 @@ export async function GET(req: NextRequest) { // Creates a new flag. export async function POST(req: NextRequest) { - const { addHeaders, rateLimitResponse } = withRateLimit(req, 'AUTH'); + edgeLog('info', '/api/admin/feature-flags', 'POST request received'); + const { addHeaders, rateLimitResponse } = withRateLimit(req, 'WRITE'); if (rateLimitResponse) return rateLimitResponse; const body = await req.json().catch(() => null); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 9f349462..a6ee2d63 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,8 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { withRateLimit } from '@/lib/ratelimit'; import type { AuthResponse } from '@/types/api'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; export async function POST(request: NextRequest) { + edgeLog('info', '/api/auth/login', 'POST request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'AUTH'); if (rateLimitResponse) { return rateLimitResponse as NextResponse; diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index 823c980f..54d40fe8 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -1,8 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { withRateLimit } from '@/lib/ratelimit'; import type { AuthResponse } from '@/types/api'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; export async function POST(request: NextRequest) { + edgeLog('info', '/api/auth/signup', 'POST request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'AUTH'); if (rateLimitResponse) { return rateLimitResponse as NextResponse; diff --git a/src/app/api/bookmarks/route.ts b/src/app/api/bookmarks/route.ts index 0343c648..06ed4684 100644 --- a/src/app/api/bookmarks/route.ts +++ b/src/app/api/bookmarks/route.ts @@ -1,6 +1,9 @@ import { NextResponse } from 'next/server'; import type { VideoBookmark, ApiResponse, SuccessResponse } from '@/types/api'; import { withRateLimit } from '@/lib/ratelimit'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; type PersistedVideoBookmark = VideoBookmark; @@ -12,6 +15,7 @@ const keyFor = (userId: string | undefined, lessonId: string) => { }; export async function GET(request: Request) { + edgeLog('info', '/api/bookmarks', 'GET request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); if (rateLimitResponse) { return rateLimitResponse as NextResponse< @@ -38,6 +42,7 @@ export async function GET(request: Request) { } export async function POST(request: Request) { + edgeLog('info', '/api/bookmarks', 'POST request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); if (rateLimitResponse) { return rateLimitResponse as NextResponse | SuccessResponse>; @@ -76,6 +81,7 @@ export async function POST(request: Request) { } export async function PATCH(request: Request) { + edgeLog('info', '/api/bookmarks', 'PATCH request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); if (rateLimitResponse) { return rateLimitResponse as NextResponse; @@ -117,6 +123,7 @@ export async function PATCH(request: Request) { } export async function DELETE(request: Request) { + edgeLog('info', '/api/bookmarks', 'DELETE request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); if (rateLimitResponse) { return rateLimitResponse as NextResponse; diff --git a/src/app/api/courses/[id]/route.ts b/src/app/api/courses/[id]/route.ts index cc61f619..13947d79 100644 --- a/src/app/api/courses/[id]/route.ts +++ b/src/app/api/courses/[id]/route.ts @@ -1,8 +1,12 @@ import { NextResponse } from 'next/server'; import type { Course, ApiResponse } from '@/types/api'; import { withRateLimit } from '@/lib/ratelimit'; +import { edgeLog, CDN_CACHE_HEADERS } from '@/../infra/edge-config'; + +export const runtime = 'edge'; export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + edgeLog('info', '/api/courses/[id]', 'GET request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'READ'); if (rateLimitResponse) { return rateLimitResponse as NextResponse>; @@ -24,10 +28,12 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: downloaded: false, }; - return addHeaders( + const response = addHeaders( NextResponse.json({ data: course, success: true, }), ); + response.headers.set('Cache-Control', CDN_CACHE_HEADERS.public); + return response; } diff --git a/src/app/api/courses/downloadable/route.ts b/src/app/api/courses/downloadable/route.ts index ad663128..ceb42b2b 100644 --- a/src/app/api/courses/downloadable/route.ts +++ b/src/app/api/courses/downloadable/route.ts @@ -1,9 +1,12 @@ import { NextResponse } from 'next/server'; import { withRateLimit } from '@/lib/ratelimit'; +import { edgeLog } from '@/../infra/edge-config'; -export async function GET() { - const mockRequest = new Request('http://localhost'); - const { addHeaders, rateLimitResponse } = withRateLimit(mockRequest, 'READ'); +export const runtime = 'edge'; + +export async function GET(request: Request) { + edgeLog('info', '/api/courses/downloadable', 'GET request received'); + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'READ'); if (rateLimitResponse) { return rateLimitResponse; } diff --git a/src/app/api/courses/route.ts b/src/app/api/courses/route.ts index b3cfd044..d5849b4e 100644 --- a/src/app/api/courses/route.ts +++ b/src/app/api/courses/route.ts @@ -1,8 +1,12 @@ import { NextResponse } from 'next/server'; import type { Course, PaginatedResponse } from '@/types/api'; import { withRateLimit } from '@/lib/ratelimit'; +import { edgeLog, CDN_CACHE_HEADERS } from '@/../infra/edge-config'; + +export const runtime = 'edge'; export async function GET(request: Request) { + edgeLog('info', '/api/courses', 'GET request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'READ'); if (rateLimitResponse) { return rateLimitResponse as NextResponse>; @@ -62,11 +66,13 @@ export async function GET(request: Request) { const nextIndex = startIndex + limit; const nextCursor = nextIndex < courses.length ? String(nextIndex) : undefined; - return addHeaders( + const response = addHeaders( NextResponse.json({ data: page, total: courses.length, nextCursor, }), ); + response.headers.set('Cache-Control', CDN_CACHE_HEADERS.public); + return response; } diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 00000000..de64d985 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { edgeLog, COLD_START_CONFIG } from '@/../infra/edge-config'; + +export const runtime = 'edge'; + +/** + * Lightweight health check endpoint for edge function monitoring and keep-alive. + * Used by the platform health checker to optimize cold starts. + */ +export async function GET() { + edgeLog('info', '/api/health', 'Health check ping received'); + + return NextResponse.json( + { + status: 'healthy', + timestamp: new Date().toISOString(), + runtime: 'edge', + config: { + timeoutMs: COLD_START_CONFIG.timeoutMs, + }, + }, + { + status: 200, + headers: { + 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', + }, + }, + ); +} diff --git a/src/app/api/lessons/[id]/progress/route.ts b/src/app/api/lessons/[id]/progress/route.ts index b08e871b..a3cefb5c 100644 --- a/src/app/api/lessons/[id]/progress/route.ts +++ b/src/app/api/lessons/[id]/progress/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from 'next/server'; import { withRateLimit } from '@/lib/ratelimit'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + edgeLog('info', '/api/lessons/[id]/progress', 'PATCH request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); if (rateLimitResponse) { return rateLimitResponse; diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts index 16311e9e..644f2e4b 100644 --- a/src/app/api/notes/route.ts +++ b/src/app/api/notes/route.ts @@ -1,6 +1,9 @@ import { NextResponse } from 'next/server'; import type { VideoNote, ApiResponse, SuccessResponse } from '@/types/api'; import { withRateLimit } from '@/lib/ratelimit'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; type PersistedVideoNote = VideoNote; @@ -12,6 +15,7 @@ const keyFor = (userId: string | undefined, lessonId: string) => { }; export async function GET(request: Request) { + edgeLog('info', '/api/notes', 'GET request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); if (rateLimitResponse) { return rateLimitResponse as NextResponse | SuccessResponse>; @@ -36,6 +40,7 @@ export async function GET(request: Request) { } export async function POST(request: Request) { + edgeLog('info', '/api/notes', 'POST request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); if (rateLimitResponse) { return rateLimitResponse as NextResponse | SuccessResponse>; @@ -72,6 +77,7 @@ export async function POST(request: Request) { } export async function PATCH(request: Request) { + edgeLog('info', '/api/notes', 'PATCH request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); if (rateLimitResponse) { return rateLimitResponse as NextResponse; @@ -111,6 +117,7 @@ export async function PATCH(request: Request) { } export async function DELETE(request: Request) { + edgeLog('info', '/api/notes', 'DELETE request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); if (rateLimitResponse) { return rateLimitResponse as NextResponse; diff --git a/src/app/api/performance/vitals/route.ts b/src/app/api/performance/vitals/route.ts index ddc49972..244a7ab2 100644 --- a/src/app/api/performance/vitals/route.ts +++ b/src/app/api/performance/vitals/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from 'next/server'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; export async function POST(request: Request) { + edgeLog('info', '/api/performance/vitals', 'POST request received'); try { const metric = await request.json(); diff --git a/src/app/api/user/progress/route.ts b/src/app/api/user/progress/route.ts index 263d07f1..54841145 100644 --- a/src/app/api/user/progress/route.ts +++ b/src/app/api/user/progress/route.ts @@ -1,10 +1,13 @@ import { NextResponse } from 'next/server'; import type { ApiResponse, UserProgress } from '@/types/api'; import { withRateLimit } from '@/lib/ratelimit'; +import { edgeLog } from '@/../infra/edge-config'; -export async function GET() { - const mockRequest = new Request('http://localhost'); - const { addHeaders, rateLimitResponse } = withRateLimit(mockRequest, 'WRITE'); +export const runtime = 'edge'; + +export async function GET(request: Request) { + edgeLog('info', '/api/user/progress', 'GET request received'); + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'READ'); if (rateLimitResponse) { return rateLimitResponse as NextResponse>; } @@ -25,6 +28,7 @@ export async function GET() { } export async function POST(request: Request) { + edgeLog('info', '/api/user/progress', 'POST request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); if (rateLimitResponse) { return rateLimitResponse as NextResponse>; diff --git a/src/app/api/user/settings/route.ts b/src/app/api/user/settings/route.ts index f8085024..1ab095a5 100644 --- a/src/app/api/user/settings/route.ts +++ b/src/app/api/user/settings/route.ts @@ -2,6 +2,9 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { withRateLimit } from '@/lib/ratelimit'; import { appSettingsSchema, createDefaultSettings, type AppSettings } from '@/lib/settings/types'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; /** * Ephemeral server-side store for syncing settings across devices for a given sync key. @@ -16,6 +19,7 @@ const putBodySchema = z.object({ }); export async function GET(request: Request) { + edgeLog('info', '/api/user/settings', 'GET request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'READ'); if (rateLimitResponse) { return rateLimitResponse; @@ -60,6 +64,7 @@ export async function GET(request: Request) { } export async function PUT(request: Request) { + edgeLog('info', '/api/user/settings', 'PUT request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); if (rateLimitResponse) { return rateLimitResponse; diff --git a/src/app/api/video-analytics/route.ts b/src/app/api/video-analytics/route.ts index df9d8a4f..9680a1c3 100644 --- a/src/app/api/video-analytics/route.ts +++ b/src/app/api/video-analytics/route.ts @@ -1,6 +1,9 @@ import { NextResponse } from 'next/server'; import type { SuccessResponse } from '@/types/api'; import { withRateLimit } from '@/lib/ratelimit'; +import { edgeLog } from '@/../infra/edge-config'; + +export const runtime = 'edge'; type AnalyticsEvent = { userId?: string; @@ -17,6 +20,7 @@ const keyFor = (userId: string | undefined, lessonId: string) => { }; export async function POST(request: Request) { + edgeLog('info', '/api/video-analytics', 'POST request received'); const { addHeaders, rateLimitResponse } = withRateLimit(request, 'WRITE'); if (rateLimitResponse) { return rateLimitResponse as NextResponse;