Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 59 additions & 0 deletions infra/edge-config.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
): 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));
}
}
6 changes: 6 additions & 0 deletions src/app/api/admin/feature-flags/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

Expand Down Expand Up @@ -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;

Expand Down
4 changes: 4 additions & 0 deletions src/app/api/admin/feature-flags/audit/route.ts
Original file line number Diff line number Diff line change
@@ -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=<id>&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;

Expand Down
4 changes: 4 additions & 0 deletions src/app/api/admin/feature-flags/evaluate/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
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=<flagId>&userId=<uid>&plan=<plan>…
*
* 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;

Expand Down
7 changes: 6 additions & 1 deletion src/app/api/admin/feature-flags/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -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<AuthResponse | { message: string }>;
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/auth/signup/route.ts
Original file line number Diff line number Diff line change
@@ -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<AuthResponse | { message: string }>;
Expand Down
7 changes: 7 additions & 0 deletions src/app/api/bookmarks/route.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<
Expand All @@ -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<ApiResponse<PersistedVideoBookmark> | SuccessResponse>;
Expand Down Expand Up @@ -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<SuccessResponse>;
Expand Down Expand Up @@ -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<SuccessResponse>;
Expand Down
8 changes: 7 additions & 1 deletion src/app/api/courses/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<Course>>;
Expand All @@ -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;
}
9 changes: 6 additions & 3 deletions src/app/api/courses/downloadable/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
8 changes: 7 additions & 1 deletion src/app/api/courses/route.ts
Original file line number Diff line number Diff line change
@@ -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<PaginatedResponse<Course>>;
Expand Down Expand Up @@ -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;
}
29 changes: 29 additions & 0 deletions src/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
);
}
4 changes: 4 additions & 0 deletions src/app/api/lessons/[id]/progress/route.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/app/api/notes/route.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<ApiResponse<PersistedVideoNote[]> | SuccessResponse>;
Expand All @@ -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<ApiResponse<PersistedVideoNote> | SuccessResponse>;
Expand Down Expand Up @@ -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<SuccessResponse>;
Expand Down Expand Up @@ -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<SuccessResponse>;
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/performance/vitals/route.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down
Loading
Loading