Files
codex_backend/main.cjs
T
2026-05-24 22:53:05 +09:00

160 lines
3.8 KiB
JavaScript

const express = require('express');
const cors = require('cors');
const {
getAuthStatus,
getLoginState,
logoutCodex,
runCodexOnce,
runCodexStream,
startLogin
} = require('./codexClient.cjs');
const app = express();
const PORT = Number(process.env.PORT || 8787);
const HOST = process.env.HOST || '127.0.0.1';
function isAllowedOrigin(origin) {
if (!origin) {
return true;
}
try {
const url = new URL(origin);
return ['localhost', '127.0.0.1', '::1'].includes(url.hostname);
} catch (_error) {
return false;
}
}
app.use(cors({
origin: function (origin, callback) {
callback(null, isAllowedOrigin(origin));
}
}));
app.use(express.json({ limit: '2mb' }));
function writeSse(res, eventName, data) {
res.write('event: ' + eventName + '\n');
res.write('data: ' + JSON.stringify(data) + '\n\n');
}
function toHttpStatus(error) {
const status = Number(error && (error.status || error.statusCode));
if (Number.isFinite(status) && status >= 400 && status < 600) {
return status;
}
return 500;
}
function toErrorMessage(error) {
if (!error) {
return '未知错误';
}
if (typeof error.message === 'string' && error.message.trim()) {
return error.message;
}
return String(error);
}
app.get('/api/health', function (_req, res) {
res.json({ ok: true, provider: 'codex-sdk' });
});
app.get('/api/codex/status', async function (_req, res) {
try {
const status = await getAuthStatus({ force: true });
res.json(status);
} catch (error) {
res.status(toHttpStatus(error)).json({ error: toErrorMessage(error) });
}
});
app.get('/api/codex/login-log', function (_req, res) {
res.json(getLoginState());
});
app.post('/api/codex/login', function (req, res) {
const state = startLogin({
deviceAuth: Boolean(req.body && req.body.deviceAuth)
});
res.status(202).json(state);
});
app.post('/api/codex/logout', async function (_req, res) {
try {
const result = await logoutCodex();
res.status(result.ok ? 200 : 500).json(result);
} catch (error) {
res.status(toHttpStatus(error)).json({ error: toErrorMessage(error) });
}
});
app.post('/api/chat', async function (req, res) {
const controller = new AbortController();
// 只有客户端真正断开时才中止 Codex 进程,避免普通请求结束误判为取消。
res.on('close', function () {
if (!res.writableEnded) {
controller.abort();
}
});
try {
const result = await runCodexOnce(req.body || {}, controller.signal);
res.json(result);
} catch (error) {
res.status(toHttpStatus(error)).json({ error: toErrorMessage(error) });
}
});
app.post('/api/chat/stream', async function (req, res) {
const controller = new AbortController();
let closed = false;
// 流式响应中,浏览器停止请求时同步中止后端 Codex 运行。
res.on('close', function () {
if (!res.writableEnded) {
closed = true;
controller.abort();
}
});
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders && res.flushHeaders();
try {
const result = await runCodexStream(req.body || {}, controller.signal, {
onDelta: function (delta) {
if (!closed) {
writeSse(res, 'delta', { delta: delta });
}
},
onError: function (message) {
if (!closed) {
writeSse(res, 'error', { error: message });
}
}
});
if (!closed) {
writeSse(res, 'done', result);
res.end();
}
} catch (error) {
if (!closed) {
writeSse(res, 'error', { error: toErrorMessage(error) });
res.end();
}
}
});
app.listen(PORT, HOST, function () {
console.log('Local Codex SDK proxy running at http://' + HOST + ':' + PORT);
});