diff --git a/README.md b/README.md index 508c60d..9481266 100644 --- a/README.md +++ b/README.md @@ -78,3 +78,33 @@ docker run -p 3000:3000 \ -e VITE_BASE_PATH=/adminpanel \ librechat-admin-panel ``` + +### Serverless (AWS Lambda) + +The panel can also run as a single AWS Lambda. The handler in `lambda.ts` detects the incoming event shape and supports both: + +- a [Lambda Function URL](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) or API Gateway HTTP API (payload format 2.0) +- an [Application Load Balancer target](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html) (`target_type = lambda`) + +The Lambda serves both the server routes and the static client assets, so no CDN or S3 bucket is needed. + +> **Note:** Returning static files from a Lambda is not what Lambdas are designed +> for — in production you would normally put a CDN (or S3 + CloudFront) in front +> and let it serve `dist/client` directly. The handler does it inline purely for +> simplicity, so a single self-contained zip deploys with no extra infrastructure. + +```bash +bun run build:lambda # outputs dist/lambda/{index.mjs, client/} +``` + +Zip the contents of `dist/lambda/` and deploy with handler `index.handler` on a Node.js runtime. Set the runtime environment variables used by the Docker deployment (`SESSION_SECRET`, `VITE_API_BASE_URL`, `ADMIN_SSO_*`, `SESSION_COOKIE_SECURE`, etc.). A `GET /health` route returns `200 ok` for health checks. + +> **Note:** The Prometheus `/metrics` endpoint (and `ADMIN_PANEL_METRICS_SECRET`) +> is specific to the long-running `server.ts` deployment and is **not** exposed by +> the Lambda — in-process pull-model metrics don't fit Lambda's ephemeral, horizontally +> scaled instances. Use CloudWatch (or another push-based exporter) for Lambda metrics. + +> **ALB targets:** enable multi-value headers on the target group so multiple +> `Set-Cookie` headers (required for the session/PKCE flow) survive. ALB caps +> Lambda responses at 1 MB; the handler gzips compressible assets to stay under +> it. With plain-HTTP listeners, also set `SESSION_COOKIE_SECURE=false` (see above). diff --git a/lambda.ts b/lambda.ts new file mode 100644 index 0000000..1b4221f --- /dev/null +++ b/lambda.ts @@ -0,0 +1,350 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { join, sep } from 'node:path'; +import { gzipSync } from 'node:zlib'; + +/** + * AWS Lambda entry point for running the admin panel serverlessly. It supports + * both event shapes a Lambda can receive from an HTTP front end: + * + * - Lambda Function URL / API Gateway HTTP API (payload format 2.0) + * - Application Load Balancer target (`target_type = lambda`) + * + * The handler detects the shape from the event and replies in the matching + * format. The same Lambda also serves the static client assets (`dist/client`, + * bundled next to the handler as `client/`), so no CDN or S3 bucket is required + * — the deployed zip is self-contained. Build it with `bun run build:lambda`. + * + * For ALB targets, enable multi-value headers on the target group so multiple + * `Set-Cookie` headers survive. + */ + +type AlbEvent = { + requestContext: { elb: { targetGroupArn: string } }; + httpMethod: string; + path: string; + queryStringParameters?: Record; + multiValueQueryStringParameters?: Record; + headers?: Record; + multiValueHeaders?: Record; + body?: string; + isBase64Encoded?: boolean; +}; + +type HttpEvent = { + requestContext: { http: { method: string } }; + rawPath: string; + rawQueryString: string; + cookies?: string[]; + headers?: Record; + body?: string; + isBase64Encoded?: boolean; +}; + +type LambdaEvent = AlbEvent | HttpEvent; + +type AlbResult = { + statusCode: number; + statusDescription: string; + multiValueHeaders: Record; + body: string; + isBase64Encoded: boolean; +}; + +type HttpResult = { + statusCode: number; + headers: Record; + cookies?: string[]; + body: string; + isBase64Encoded: boolean; +}; + +type Result = { + status: number; + statusText?: string; + headers: Record; + cookies: string[]; + body: string; + isBase64Encoded: boolean; +}; + +type FetchHandler = { default: { fetch: (request: Request) => Promise } }; + +const { default: app } = (await import('./dist/server/server.js')) as FetchHandler; + +const CLIENT_DIR = join(fileURLToPath(new URL('.', import.meta.url)), 'client'); +const BASE_PATH = (process.env.VITE_BASE_PATH || '').replace(/\/$/, ''); +const NO_CACHE = 'no-cache, no-store, must-revalidate'; +const IMMUTABLE = 'public, max-age=31536000, immutable'; +const NEVER_CACHE = new Set(['/manifest.json', '/robots.txt', '/sw.js']); + +// Mirrors server.ts. Shipped report-only by default because TanStack Start's SSR +// injects an inline boot script; set ADMIN_PANEL_CSP_ENFORCE=true to enforce once +// a per-request nonce is wired up. +const CSP_VALUE = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self'", + "object-src 'none'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", +].join('; '); +const CSP_HEADER_NAME = + process.env.ADMIN_PANEL_CSP_ENFORCE === 'true' + ? 'content-security-policy' + : 'content-security-policy-report-only'; + +function applySecurityHeaders(headers: Record): void { + if (!(headers['content-type'] ?? '').toLowerCase().startsWith('text/html')) return; + headers[CSP_HEADER_NAME] = CSP_VALUE; + headers['x-content-type-options'] = 'nosniff'; + headers['referrer-policy'] = 'strict-origin-when-cross-origin'; + headers['x-frame-options'] = 'DENY'; + if (process.env.NODE_ENV === 'production') { + headers['strict-transport-security'] = 'max-age=31536000; includeSubDomains'; + } +} + +// ALB requires statusDescription to be a " " line; a bare code +// (e.g. "307") makes the ALB return 502 Bad Gateway. +const REASON_PHRASES: Record = { + 200: 'OK', + 204: 'No Content', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 307: 'Temporary Redirect', + 308: 'Permanent Redirect', + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 429: 'Too Many Requests', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', +}; + +const CONTENT_TYPES: Record = { + js: 'text/javascript', + mjs: 'text/javascript', + css: 'text/css', + html: 'text/html; charset=utf-8', + json: 'application/json', + svg: 'image/svg+xml', + ico: 'image/x-icon', + png: 'image/png', + jpg: 'image/jpeg', + webp: 'image/webp', + woff: 'font/woff', + woff2: 'font/woff2', + txt: 'text/plain', + map: 'application/json', +}; + +function isAlb(event: LambdaEvent): event is AlbEvent { + return 'elb' in event.requestContext; +} + +function contentType(pathname: string): string { + const ext = pathname.split('.').pop()?.toLowerCase() ?? ''; + return CONTENT_TYPES[ext] ?? 'application/octet-stream'; +} + +function isStaticPath(pathname: string): boolean { + return ( + pathname.startsWith('/assets/') || + pathname.endsWith('.svg') || + pathname === '/favicon.ico' || + NEVER_CACHE.has(pathname) + ); +} + +function isCompressible(contentType: string): boolean { + return /javascript|css|json|html|svg|text|xml|manifest/.test(contentType); +} + +// ALB Lambda targets cap the response at 1 MB (Function URLs and API Gateway +// allow ~6 MB). Gzipping compressible assets keeps large chunks (e.g. the icons +// bundle) under the cap and cuts transfer overall. +function encodeBody( + buffer: Buffer, + contentType: string, + gzip: boolean, +): { body: string; contentEncoding?: string } { + if (gzip && isCompressible(contentType) && buffer.length > 1024) { + return { body: gzipSync(buffer).toString('base64'), contentEncoding: 'gzip' }; + } + return { body: buffer.toString('base64') }; +} + +function albQueryString(event: AlbEvent): string { + const params = new URLSearchParams(); + if (event.multiValueQueryStringParameters) { + for (const [key, values] of Object.entries(event.multiValueQueryStringParameters)) { + for (const value of values) params.append(key, value); + } + } else if (event.queryStringParameters) { + for (const [key, value] of Object.entries(event.queryStringParameters)) params.append(key, value); + } + return params.toString(); +} + +function buildRequest(event: LambdaEvent): Request { + const headers = new Headers(); + if (isAlb(event)) { + if (event.multiValueHeaders) { + for (const [name, values] of Object.entries(event.multiValueHeaders)) { + for (const value of values) headers.append(name, value); + } + } else if (event.headers) { + for (const [name, value] of Object.entries(event.headers)) headers.set(name, value); + } + } else { + if (event.headers) { + for (const [name, value] of Object.entries(event.headers)) headers.set(name, value); + } + if (event.cookies?.length) headers.set('cookie', event.cookies.join('; ')); + } + + const method = isAlb(event) ? event.httpMethod : event.requestContext.http.method; + const path = isAlb(event) ? event.path : event.rawPath; + const rawQuery = isAlb(event) ? albQueryString(event) : event.rawQueryString; + + const host = headers.get('x-forwarded-host') ?? headers.get('host') ?? 'localhost'; + const proto = headers.get('x-forwarded-proto') ?? 'https'; + const url = `${proto}://${host}${path}${rawQuery ? `?${rawQuery}` : ''}`; + + const hasBody = event.body != null && method !== 'GET' && method !== 'HEAD'; + const body = hasBody + ? event.isBase64Encoded + ? Buffer.from(event.body as string, 'base64') + : event.body + : undefined; + + return new Request(url, { method, headers, body }); +} + +async function serveStatic(pathname: string, gzip: boolean): Promise { + const filePath = join(CLIENT_DIR, pathname); + if (filePath !== CLIENT_DIR && !filePath.startsWith(CLIENT_DIR + sep)) return null; + try { + const data = await readFile(filePath); + const ct = contentType(pathname); + const cache = pathname.startsWith('/assets/') ? IMMUTABLE : NEVER_CACHE.has(pathname) ? NO_CACHE : ''; + const encoded = encodeBody(data, ct, gzip); + const headers: Record = { 'content-type': ct }; + if (cache) headers['cache-control'] = cache; + if (encoded.contentEncoding) { + headers['content-encoding'] = encoded.contentEncoding; + headers['vary'] = 'accept-encoding'; + } + return { status: 200, headers, cookies: [], body: encoded.body, isBase64Encoded: true }; + } catch { + return null; + } +} + +async function toResult(response: Response, gzip: boolean): Promise { + const headers: Record = {}; + response.headers.forEach((value, key) => { + if (key !== 'set-cookie') headers[key] = value; + }); + if (!headers['cache-control']) headers['cache-control'] = NO_CACHE; + + const cookies = + typeof response.headers.getSetCookie === 'function' ? response.headers.getSetCookie() : []; + + const buffer = Buffer.from(await response.arrayBuffer()); + const ct = headers['content-type'] ?? ''; + const alreadyEncoded = 'content-encoding' in headers; + const encoded = encodeBody(buffer, ct, gzip && !alreadyEncoded); + if (encoded.contentEncoding) { + headers['content-encoding'] = encoded.contentEncoding; + headers['vary'] = headers['vary'] ? `${headers['vary']}, accept-encoding` : 'accept-encoding'; + delete headers['content-length']; + } + + return { + status: response.status, + statusText: response.statusText, + headers, + cookies, + body: encoded.body, + isBase64Encoded: true, + }; +} + +async function route(path: string, request: Request, gzip: boolean): Promise { + if (path === '/health') { + return { + status: 200, + headers: { 'content-type': 'text/plain' }, + cookies: [], + body: 'ok', + isBase64Encoded: false, + }; + } + + // Redirect the bare base path to its trailing-slash form, matching server.ts. + if (BASE_PATH && path === BASE_PATH) { + return { + status: 302, + headers: { location: `${BASE_PATH}/` }, + cookies: [], + body: '', + isBase64Encoded: false, + }; + } + + // Assets live on disk without the base prefix; strip it (matching server.ts) + // before the static lookup, but hand the app the original request — its router + // is configured with the base path. When a base path is set, only serve assets + // under it (server.ts exposes client files only at `${BASE_PATH}/…`). + const hasBase = BASE_PATH ? path.startsWith(`${BASE_PATH}/`) : true; + const assetPath = hasBase && BASE_PATH ? path.slice(BASE_PATH.length) : path; + + if (hasBase && isStaticPath(assetPath)) { + const asset = await serveStatic(assetPath, gzip); + if (asset) return asset; + } + + return toResult(await app.fetch(request), gzip); +} + +function toAlbResult(result: Result): AlbResult { + const multiValueHeaders: Record = {}; + for (const [name, value] of Object.entries(result.headers)) multiValueHeaders[name] = [value]; + if (result.cookies.length) multiValueHeaders['set-cookie'] = result.cookies; + return { + statusCode: result.status, + statusDescription: `${result.status} ${result.statusText || REASON_PHRASES[result.status] || 'OK'}`, + multiValueHeaders, + body: result.body, + isBase64Encoded: result.isBase64Encoded, + }; +} + +function toHttpResult(result: Result): HttpResult { + return { + statusCode: result.status, + headers: result.headers, + cookies: result.cookies.length ? result.cookies : undefined, + body: result.body, + isBase64Encoded: result.isBase64Encoded, + }; +} + +export async function handler(event: LambdaEvent): Promise { + const path = isAlb(event) ? event.path : event.rawPath; + const request = buildRequest(event); + const gzip = (request.headers.get('accept-encoding') ?? '').includes('gzip'); + const result = await route(path, request, gzip); + applySecurityHeaders(result.headers); + return isAlb(event) ? toAlbResult(result) : toHttpResult(result); +} diff --git a/package.json b/package.json index 36aeea3..8db49d4 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "reinstall": "rm -rf node_modules && bun pm cache rm && bun install && bun run build", "dev": "vite dev --port 3000", "build": "vite build", + "build:lambda": "vite build && bun build ./lambda.ts --target=node --format=esm --outfile=dist/lambda/index.mjs && rm -rf dist/lambda/client && cp -r dist/client dist/lambda/client", "start": "bun server.ts", "preview": "vite preview", "test": "vitest run", diff --git a/tsconfig.json b/tsconfig.json index 3dd5492..7074f53 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["scripts", "server.ts", "tools"], + "exclude": ["scripts", "server.ts", "lambda.ts", "tools"], "compilerOptions": { "target": "ES2022", "jsx": "react-jsx",