src/index.js
Rama master · réplica de lectura del repositorio original.
/*
* WindsurfAPI · DevinAPI — 把 Windsurf / Devin 的云端模型变成 OpenAI / Anthropic / Gemini 标准 API。
* 零 npm 运行时依赖。
*
* 项目完整历史(提交/版本/PR/issue/自伤账本)可视化:
* https://dwgx.github.io/WindsurfAPI/HISTORY-LEDGER-VIZ.html
* 总账: docs/HISTORY-LEDGER.md · 逐条账: docs/HISTORY-LEDGER-2026-*.md
*/
// Logger must be imported first to patch log functions before other modules use them
import './dashboard/logger.js';
import { initAuth, isAuthenticated } from './auth.js';
import { configureLanguageServer, startLanguageServer, waitForReady, isLanguageServerRunning, stopLanguageServer, stopLanguageServerAndWait, cleanupOrphanLanguageServers, shouldPrewarmDefaultLs } from './langserver.js';
import { startServer } from './server.js';
import { config, log } from './config.js';
import { existsSync, mkdirSync, readdirSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { execSync } from 'child_process';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { VERSION, BRAND } from './version.js';
import { abortActiveSse } from './sse-registry.js';
import { registerServer } from './server-registry.js';
import { buildSubprocessEnv } from './log-safety.js';
export { VERSION, BRAND };
// v2.0.146 (audit F-1): last-resort process safety nets. This service must
// not crash — it has fire-and-forget async paths (LS warmup, restart driver,
// self-update timers) where a stray rejection or a throw inside an async
// callback would otherwise terminate the process on Node >=15. A rejection is
// logged and the process keeps serving; an uncaught exception is logged and we
// exit non-zero so the supervisor (systemd) restarts from a clean state rather
// than continuing in an unknown one. Registered here (after the logger import,
// before main()) so the nets are live before any other module does async work.
let _crashNetsInstalled = false;
function installProcessCrashNets() {
if (_crashNetsInstalled) return;
_crashNetsInstalled = true;
process.on('unhandledRejection', (reason) => {
try { log.error('unhandledRejection:', reason instanceof Error ? (reason.stack || reason.message) : reason); }
catch { console.error('unhandledRejection:', reason); }
});
process.on('uncaughtException', (err) => {
try { log.error('uncaughtException — exiting for clean restart:', err?.stack || err); }
catch { console.error('uncaughtException:', err); }
process.exit(1);
});
}
installProcessCrashNets();
function workspaceBase() {
const tmpDir = process.env.TEMP || process.env.TMP || tmpdir();
const suffix = process.env.HOSTNAME ? `-${process.env.HOSTNAME}` : '';
return join(tmpDir, `windsurf-workspace${suffix}`);
}
function resetWorkspace() {
// Wipe the workspace on every startup. If we don't, files created by
// previous chat sessions (e.g. Claude "editing" config.yaml/lru_cache.py
// via the baked-in Cascade tool prompts) persist and pollute the next
// request — the model sees them at session init and starts narrating
// edits to files the caller never mentioned.
//
// Using Node fs APIs instead of an `execSync('mkdir -p ... && rm -rf')`
// shell pipeline so this is correct on Windows, macOS, and Linux without
// depending on a POSIX shell.
const wsBase = workspaceBase();
try {
mkdirSync(wsBase, { recursive: true });
for (const name of readdirSync(wsBase)) {
try { rmSync(join(wsBase, name), { recursive: true, force: true }); } catch {}
}
} catch {}
}
async function main() {
const banner = `
_ _ _ _ __ _ ____ ___
| | | (_) | | / _| / \\ | _ \\_ _|
| | | |_ _ __ __| |___ _ _ _ __ _| |_ / _ \\ | |_) | |
| |/\\| | | '_ \\ / _\` / __| | | | '__|_ _|/ ___ \\| __/| |
\\ /\\ / | | | | (_| \\__ \\ |_| | | |_| /_/ \\_\\_| |___|
\\/ \\/|_|_| |_|\\__,_|___/\\__,_|_|
${BRAND} v${VERSION}
`;
console.log(banner);
console.log(` OpenAI-compatible proxy for Windsurf — by dwgx1337\n`);
// Start language server binary.
// Auto-install if missing — users repeatedly miss the manual install step
// and open "request crashes" issues (see #18), so we just do it ourselves.
// Skipped on Windows (LS is Linux-only) and when install-ls.sh isn't present.
//
// DEVIN_CONNECT / DEVIN_ONLY are the binary-less pivot backends (pure HTTP to
// Devin cloud — no language_server needed). In those modes the whole LS block
// is pure friction: a fresh Docker boot would otherwise shell out to download
// a ~100MB+ binary the deploy never uses, delaying startup. Skip it entirely.
const lsBackendUnused = String(process.env.DEVIN_CONNECT || '').trim() === '1'
|| String(process.env.DEVIN_ONLY || '').trim() === '1';
const binaryPath = config.lsBinaryPath;
if (lsBackendUnused) {
log.info('DEVIN_CONNECT/DEVIN_ONLY enabled — skipping language server startup (binary-less backend).');
} else if (!existsSync(binaryPath) && process.platform === 'win32') {
log.warn('Windows detected: the Language Server binary is Linux/macOS only.');
log.warn('Options: (1) Use Docker (see docker-compose.yml), (2) Use WSL2, or');
log.warn('(3) Point LS_BINARY_PATH to a Windsurf desktop app language_server binary.');
}
if (!lsBackendUnused && !existsSync(binaryPath) && process.platform !== 'win32') {
const scriptPath = (() => {
try {
const here = dirname(fileURLToPath(import.meta.url));
return join(here, '..', 'install-ls.sh');
} catch { return null; }
})();
if (scriptPath && existsSync(scriptPath)) {
log.info(`Language server binary missing at ${binaryPath}`);
log.info(`Auto-installing via ${scriptPath} — this runs once.`);
try {
// Bounded so a slow/black-holed network can't hang boot forever. The
// upstream desktop archive is ~345 MB; install-ls.sh allows 60 s for
// the manifest plus 900 s for the archive, so leave headroom for
// extraction and hashing.
execSync(`bash "${scriptPath}"`, {
stdio: 'inherit',
// Denylist env (L3b env-leak fix): the installer needs the general
// env — PATH for curl/node/sha256sum, HOME, TMPDIR, proxy + CA-trust
// vars for the downloads, WINDSURFAPI_LS_* overrides — but not the
// proxy's credentials. Nothing install-ls.sh reads is secret-shaped.
env: { ...buildSubprocessEnv(), LS_INSTALL_PATH: binaryPath },
timeout: 1020000,
});
log.info('Language server binary installed.');
} catch (err) {
log.error(`Auto-install failed: ${err.message}`);
log.error('Run manually: bash install-ls.sh (or set LS_BINARY_PATH to point at an existing binary)');
}
}
}
if (!lsBackendUnused && existsSync(binaryPath)) {
resetWorkspace();
// v2.0.85 (#127 123cek): kill any leftover language_server_linux_x64
// processes from prior runs (PM2 SIGKILL / dashboard self-update via
// process.exit() / earlier crash) before we start ours. Otherwise
// they keep their LS pool ports occupied and accumulate over self-
// update cycles. Setting WINDSURFAPI_SKIP_LS_CLEANUP=1 disables
// (e.g. multi-WindsurfAPI on a shared host).
if (process.env.WINDSURFAPI_SKIP_LS_CLEANUP !== '1') {
try {
const r = cleanupOrphanLanguageServers();
if (r.killed > 0) log.info(`LS cleanup: scanned ${r.scanned} candidate(s), killed ${r.killed} orphan(s)`);
} catch (e) { log.warn(`LS cleanup error (non-fatal): ${e.message}`); }
}
const lsConfig = {
binaryPath,
port: config.lsPort,
apiServerUrl: config.codeiumApiUrl,
};
configureLanguageServer(lsConfig);
if (shouldPrewarmDefaultLs()) {
await startLanguageServer(lsConfig);
try {
await waitForReady(30000);
// v2.0.93: if default LS started but proxy-LS crashed, give the
// manage child (port 42101) a moment to restart before syncing models.
log.info('LS ready — fetching model catalog');
} catch (err) {
log.error(`Language server failed to start: ${err.message}`);
log.error('Chat completions will not work without the language server.');
log.error('Run: bash install-ls.sh (pinned upstream manifest; no fallback source)');
}
} else {
log.info('LS default prewarm disabled (LS_PREWARM_DEFAULT=0 or LS_MAX_INSTANCES=1); LS starts lazily on first request');
}
} else if (!lsBackendUnused) {
log.warn(`Language server binary not found at ${binaryPath}`);
log.warn('Install it with: download Windsurf Linux tarball and extract language_server_linux_x64');
}
// Init auth pool
await initAuth();
if (!isAuthenticated()) {
log.warn('No accounts configured. Add via:');
log.warn(' POST /auth/login {"token":"..."}');
log.warn(' POST /auth/login {"api_key":"..."}');
}
const server = startServer();
// Expose the live server so dashboard-triggered restart / self-update paths
// in api.js can drain in-flight requests before exiting (they otherwise have
// no reference to it). Mirrors the sse-registry pattern.
registerServer(server);
// Packaged first-run: print the auto-generated credentials prominently (like
// start.ps1 does for the source install) so the user can log in / call the
// API. They're already persisted to the .env beside the exe.
if (config.generatedCreds) {
const c = config.generatedCreds;
log.warn('################################################################');
log.warn('# 已为你自动生成密钥(请截图妥善保存) #');
log.warn('################################################################');
// D6-LOGS (audit 2026-09-22): only these two lines carry generated credentials,
// and `log.warn` is patched by dashboard/logger.js to copy every call into the
// dashboard ring buffer, the live SSE subscribers and logs/{app,error}-<date>.jsonl
// — sinks that outlive the session and travel into bug reports, container logs and
// backups. The first-run display exists for the operator in front of the console,
// so it goes straight to console.warn; the banner, the .env path and the failure
// explanation below stay on the logger, where operators expect to find them.
if (c.DASHBOARD_PASSWORD) console.warn(` 面板登录密码 (DASHBOARD_PASSWORD): ${c.DASHBOARD_PASSWORD}`);
if (c.API_KEY) console.warn(` 网关调用密钥 (API_KEY): ${c.API_KEY}`);
if (c.envPath) log.warn(` 已写入 ${c.envPath} — 下次启动沿用,无需重设。`);
else log.warn(` (写入 .env 失败${c.persistError ? ': ' + c.persistError : ''} — 本次有效,重启会重新生成)`);
log.warn('################################################################');
}
// Packaged single-exe desktop UX: on the FIRST deploy (no Windsurf_data folder
// existed yet), pop the dashboard in the default browser so the initial
// double-click lands on a usable setup page. Subsequent launches don't reopen
// it. Best effort — a headless/CI run (WINDSURFAPI_NO_OPEN=1) or a missing
// opener just skips it. Delayed slightly so the "Server on ..." line prints.
if (config.isFirstRun && process.env.WINDSURFAPI_NO_OPEN !== '1') {
const url = `http://127.0.0.1:${config.port}/dashboard`;
setTimeout(() => {
try {
// Windows: `cmd /c start "" <url>` opens the default browser without a
// shell window lingering. execFile avoids spawning a persistent shell.
import('child_process').then(({ execFile }) => {
if (process.platform === 'win32') {
execFile('cmd', ['/c', 'start', '', url], { windowsHide: true }, () => {});
} else if (process.platform === 'darwin') {
execFile('open', [url], () => {});
} else {
execFile('xdg-open', [url], () => {});
}
}).catch(() => {});
log.info(`First run — opening dashboard in your browser: ${url}`);
} catch { /* opener unavailable — user can open the URL manually */ }
}, 800);
}
let shuttingDown = false;
const shutdown = (signal) => {
if (shuttingDown) return;
shuttingDown = true;
const inflight = server.getActiveRequests?.() ?? '?';
log.info(`${signal} received — draining ${inflight} in-flight requests (up to 30s)...`);
const abortedSse = abortActiveSse('server shutting down');
if (abortedSse) log.warn(`Aborted ${abortedSse} active SSE stream(s): server shutting down`);
// Detach P1: entries are in-memory and hold no LS lease, but a drain must
// refuse new admissions and drop what exists so a dying process does not
// leave claims behind while the LS pool is being stopped.
import('./detached-registry.js').then(m => m.beginDetachDrain()).catch(() => {});
if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections();
// v2.0.146 (audit F-2): await LS children actually exiting before
// process.exit, mirroring the self-update path. A synchronous
// stopLanguageServer() + immediate exit reparents still-living LS
// children to PID 1, leaving them holding pool ports (42100, 42101…)
// that the freshly-restarted replica then races (the H-4 orphan race).
const finalize = async (reason) => {
// K7: shutdown DRAINS, it does not write the pool back. The periodic
// dirty-flush keeps accounts.json current (≤30s stale) and status flips
// save immediately, so a shutdown rewrite buys nothing — and it used to
// clobber an operator's external accounts.json write, forcing the
// "stop → wait for the flush hook → write → start" deploy dance. Removing
// it lets an external write land safely once the process is stopped.
try { await stopLanguageServerAndWait({ perProcessTimeoutMs: 1500 }); }
catch { try { stopLanguageServer(); } catch {} }
log.info(`Shutdown complete (${reason})`);
process.exit(0);
};
server.close(() => {
log.info('HTTP server closed, stopping language server');
void finalize('drained');
});
setTimeout(() => {
log.warn('Drain timeout, forcing exit');
void finalize('drain-timeout');
}, 30_000);
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
main().catch(err => { console.error('Fatal:', err); process.exit(1); });