Skip to content
Open
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
350 changes: 350 additions & 0 deletions lambda.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
multiValueQueryStringParameters?: Record<string, string[]>;
headers?: Record<string, string>;
multiValueHeaders?: Record<string, string[]>;
body?: string;
isBase64Encoded?: boolean;
};

type HttpEvent = {
requestContext: { http: { method: string } };
rawPath: string;
rawQueryString: string;
cookies?: string[];
headers?: Record<string, string>;
body?: string;
isBase64Encoded?: boolean;
};

type LambdaEvent = AlbEvent | HttpEvent;

type AlbResult = {
statusCode: number;
statusDescription: string;
multiValueHeaders: Record<string, string[]>;
body: string;
isBase64Encoded: boolean;
};

type HttpResult = {
statusCode: number;
headers: Record<string, string>;
cookies?: string[];
body: string;
isBase64Encoded: boolean;
};

type Result = {
status: number;
statusText?: string;
headers: Record<string, string>;
cookies: string[];
body: string;
isBase64Encoded: boolean;
};

type FetchHandler = { default: { fetch: (request: Request) => Promise<Response> } };

const { default: app } = (await import('./dist/server/server.js')) as FetchHandler;
Comment thread
busla marked this conversation as resolved.

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<string, string>): 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 "<code> <reason>" line; a bare code
// (e.g. "307") makes the ALB return 502 Bad Gateway.
const REASON_PHRASES: Record<number, string> = {
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<string, string> = {
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)
);
Comment thread
cursor[bot] marked this conversation as resolved.
}

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<Result | null> {
const filePath = join(CLIENT_DIR, pathname);
if (filePath !== CLIENT_DIR && !filePath.startsWith(CLIENT_DIR + sep)) return null;
Comment thread
busla marked this conversation as resolved.
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<string, string> = { '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<Result> {
const headers: Record<string, string> = {};
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,
};
Comment thread
cursor[bot] marked this conversation as resolved.
}

async function route(path: string, request: Request, gzip: boolean): Promise<Result> {
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;
}
Comment thread
cursor[bot] marked this conversation as resolved.

return toResult(await app.fetch(request), gzip);
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}

function toAlbResult(result: Result): AlbResult {
const multiValueHeaders: Record<string, string[]> = {};
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<AlbResult | HttpResult> {
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);
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading