This commit is contained in:
232
server/index.mjs
Normal file
232
server/index.mjs
Normal file
@@ -0,0 +1,232 @@
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const PORT = Number(process.env.PORT || 80);
|
||||
const DIST_DIR = path.resolve(process.cwd(), 'dist');
|
||||
|
||||
const DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
|
||||
function getContentType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.html':
|
||||
return 'text/html; charset=utf-8';
|
||||
case '.js':
|
||||
return 'text/javascript; charset=utf-8';
|
||||
case '.css':
|
||||
return 'text/css; charset=utf-8';
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.ico':
|
||||
return 'image/x-icon';
|
||||
case '.json':
|
||||
return 'application/json; charset=utf-8';
|
||||
case '.map':
|
||||
return 'application/json; charset=utf-8';
|
||||
case '.woff2':
|
||||
return 'font/woff2';
|
||||
case '.txt':
|
||||
return 'text/plain; charset=utf-8';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, body) {
|
||||
const payload = JSON.stringify(body);
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
async function readJsonBody(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function applyApiCorsHeaders(req, res) {
|
||||
const origin = req.headers.origin;
|
||||
if (!origin) return;
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
}
|
||||
|
||||
async function handleChatCompletions(req, res) {
|
||||
applyApiCorsHeaders(req, res);
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: 'Method Not Allowed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey =
|
||||
process.env.DASHSCOPE_API_KEY ||
|
||||
process.env.VITE_API_KEY ||
|
||||
process.env.DASHSCOPE_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
sendJson(res, 500, { error: 'Missing DASHSCOPE_API_KEY in server environment' });
|
||||
return;
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch (e) {
|
||||
sendJson(res, 400, { error: 'Invalid JSON request body' });
|
||||
return;
|
||||
}
|
||||
|
||||
const upstreamUrl = `${DASHSCOPE_BASE_URL}/chat/completions`;
|
||||
const abortController = new AbortController();
|
||||
|
||||
req.on('close', () => {
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await fetch(upstreamUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body ?? {}),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
sendJson(res, 502, { error: 'Upstream request failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isStream = Boolean(body && body.stream);
|
||||
|
||||
res.statusCode = upstream.status;
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
upstream.headers.get('content-type') ||
|
||||
(isStream ? 'text/event-stream; charset=utf-8' : 'application/json; charset=utf-8')
|
||||
);
|
||||
|
||||
// Help prevent buffering in common reverse proxies.
|
||||
res.setHeader('Cache-Control', isStream ? 'no-cache, no-transform' : 'no-store');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
|
||||
// For streaming: flush headers ASAP so the browser can start rendering.
|
||||
if (isStream) {
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
}
|
||||
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
||||
|
||||
if (!upstream.body) {
|
||||
const text = await upstream.text().catch(() => '');
|
||||
res.end(text);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const reader = upstream.body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) res.write(Buffer.from(value));
|
||||
}
|
||||
} catch (e) {
|
||||
// If client disconnected, ignore; otherwise just end response.
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
function serveStatic(req, res, url) {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.statusCode = 405;
|
||||
res.end('Method Not Allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
|
||||
// Prevent path traversal.
|
||||
const safePath = path.normalize(pathname).replace(/^(\.\.(\/|\\|$))+/, '');
|
||||
const fsPath = path.join(DIST_DIR, safePath);
|
||||
|
||||
let resolved = fsPath;
|
||||
|
||||
try {
|
||||
if (pathname.endsWith('/')) {
|
||||
resolved = path.join(fsPath, 'index.html');
|
||||
} else if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
||||
resolved = path.join(resolved, 'index.html');
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
||||
// SPA history fallback.
|
||||
resolved = path.join(DIST_DIR, 'index.html');
|
||||
}
|
||||
} catch {
|
||||
resolved = path.join(DIST_DIR, 'index.html');
|
||||
}
|
||||
|
||||
const isAsset = pathname.startsWith('/assets/');
|
||||
const contentType = getContentType(resolved);
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader(
|
||||
'Cache-Control',
|
||||
isAsset ? 'public, max-age=31536000, immutable' : 'no-cache'
|
||||
);
|
||||
|
||||
if (req.method === 'HEAD') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
fs.createReadStream(resolved).pipe(res);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||
|
||||
if (url.pathname === '/api/chat/completions') {
|
||||
await handleChatCompletions(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
serveStatic(req, res, url);
|
||||
} catch (e) {
|
||||
res.statusCode = 500;
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server listening on :${PORT}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user