codex-endpoint-switcher 1.3.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/bin/codex-switcher.js +4 -1
- package/package.json +1 -1
- package/src/main/main.js +10 -0
- package/src/main/preload.js +3 -0
- package/src/renderer/index.html +27 -1
- package/src/renderer/renderer.js +19 -0
- package/src/renderer/styles.css +99 -5
- package/src/web/launcher.js +105 -1
- package/src/web/proxy-server.js +152 -24
- package/src/web/server.js +27 -2
package/README.md
CHANGED
|
@@ -48,6 +48,7 @@ codex-switcher open
|
|
|
48
48
|
codex-switcher start
|
|
49
49
|
codex-switcher status
|
|
50
50
|
codex-switcher restart
|
|
51
|
+
codex-switcher stop
|
|
51
52
|
codex-switcher sync-server
|
|
52
53
|
codex-switcher install-access
|
|
53
54
|
codex-switcher remove-access
|
|
@@ -59,6 +60,7 @@ codex-switcher remove-access
|
|
|
59
60
|
- `start`:仅在后台启动本地网页服务
|
|
60
61
|
- `status`:查看当前服务状态
|
|
61
62
|
- `restart`:重启本地网页服务
|
|
63
|
+
- `stop`:关闭本地网页服务
|
|
62
64
|
- `sync-server`:启动账号同步服务,服务端使用 SQLite 单文件数据库
|
|
63
65
|
- `install-access`:创建桌面快捷方式和开机启动项
|
|
64
66
|
- `remove-access`:移除桌面快捷方式和开机启动项
|
package/bin/codex-switcher.js
CHANGED
|
@@ -16,6 +16,7 @@ function printUsage() {
|
|
|
16
16
|
codex-switcher start
|
|
17
17
|
codex-switcher status
|
|
18
18
|
codex-switcher restart
|
|
19
|
+
codex-switcher stop
|
|
19
20
|
codex-switcher sync-server
|
|
20
21
|
codex-switcher install-access
|
|
21
22
|
codex-switcher remove-access
|
|
@@ -25,6 +26,7 @@ function printUsage() {
|
|
|
25
26
|
start 仅在后台启动本地网页服务
|
|
26
27
|
status 查看服务状态
|
|
27
28
|
restart 重启本地网页服务
|
|
29
|
+
stop 关闭本地网页服务
|
|
28
30
|
sync-server 启动账号同步服务(SQLite)
|
|
29
31
|
install-access 创建桌面快捷方式与开机启动项
|
|
30
32
|
remove-access 删除桌面快捷方式与开机启动项
|
|
@@ -82,7 +84,8 @@ async function main() {
|
|
|
82
84
|
case "open":
|
|
83
85
|
case "start":
|
|
84
86
|
case "status":
|
|
85
|
-
case "restart":
|
|
87
|
+
case "restart":
|
|
88
|
+
case "stop": {
|
|
86
89
|
await handleCommand(command === "start" ? "ensure" : command);
|
|
87
90
|
return;
|
|
88
91
|
}
|
package/package.json
CHANGED
package/src/main/main.js
CHANGED
|
@@ -91,6 +91,16 @@ function registerHandlers() {
|
|
|
91
91
|
const openError = await shell.openPath(targetPath);
|
|
92
92
|
return openError || "";
|
|
93
93
|
});
|
|
94
|
+
|
|
95
|
+
ipcMain.handle("app:close", async () => {
|
|
96
|
+
setTimeout(() => {
|
|
97
|
+
app.quit();
|
|
98
|
+
}, 60);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
closing: true,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
94
104
|
}
|
|
95
105
|
|
|
96
106
|
app.whenReady().then(() => {
|
package/src/main/preload.js
CHANGED
package/src/renderer/index.html
CHANGED
|
@@ -14,10 +14,35 @@
|
|
|
14
14
|
<div class="page-shell">
|
|
15
15
|
<section id="authGate" class="auth-gate">
|
|
16
16
|
<div class="auth-card panel">
|
|
17
|
+
<section class="auth-hero-card">
|
|
18
|
+
<p class="eyebrow">CODEX ENDPOINT SWITCHER</p>
|
|
19
|
+
<h1 class="auth-hero-title">登录后直接进入连接控制台</h1>
|
|
20
|
+
<p class="auth-hero-copy">
|
|
21
|
+
连接切换、账号同步、远端拉取都绑定到同一账号空间里,进入后即可直接管理。
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
<div class="auth-highlight-list">
|
|
25
|
+
<article class="auth-highlight-item">
|
|
26
|
+
<span class="auth-highlight-label">登录后可用</span>
|
|
27
|
+
<strong>推送当前连接</strong>
|
|
28
|
+
<p>把本机 URL、Key、备注同步到账号空间。</p>
|
|
29
|
+
</article>
|
|
30
|
+
<article class="auth-highlight-item">
|
|
31
|
+
<span class="auth-highlight-label">同步能力</span>
|
|
32
|
+
<strong>合并或覆盖拉取</strong>
|
|
33
|
+
<p>在不同设备之间快速恢复同一套连接配置。</p>
|
|
34
|
+
</article>
|
|
35
|
+
<article class="auth-highlight-item">
|
|
36
|
+
<span class="auth-highlight-label">固定服务</span>
|
|
37
|
+
<strong>自动连接账号同步服务</strong>
|
|
38
|
+
<p>这里只需要输入账号和密码,不需要再配置服务地址。</p>
|
|
39
|
+
</article>
|
|
40
|
+
</div>
|
|
41
|
+
</section>
|
|
42
|
+
|
|
17
43
|
<div class="auth-form-card">
|
|
18
44
|
<div class="panel-header auth-panel-header">
|
|
19
45
|
<div>
|
|
20
|
-
<p class="eyebrow">CODEX ENDPOINT SWITCHER</p>
|
|
21
46
|
<p class="panel-kicker">Account Access</p>
|
|
22
47
|
<h2>账号登录</h2>
|
|
23
48
|
</div>
|
|
@@ -82,6 +107,7 @@
|
|
|
82
107
|
<button id="refreshButton" class="ghost-button">刷新状态</button>
|
|
83
108
|
<button id="openCodexRootButton" class="ghost-button">打开 Codex 目录</button>
|
|
84
109
|
<button id="openEndpointStoreButton" class="ghost-button">打开连接数据文件</button>
|
|
110
|
+
<button id="closeAppButton" class="danger-button">关闭程序</button>
|
|
85
111
|
</div>
|
|
86
112
|
<div class="current-grid">
|
|
87
113
|
<article class="metric-card">
|
package/src/renderer/renderer.js
CHANGED
|
@@ -100,6 +100,11 @@ function createWebBridge() {
|
|
|
100
100
|
body: JSON.stringify({ targetPath }),
|
|
101
101
|
}).then(() => "");
|
|
102
102
|
},
|
|
103
|
+
closeApp() {
|
|
104
|
+
return request("/api/app/close", {
|
|
105
|
+
method: "POST",
|
|
106
|
+
});
|
|
107
|
+
},
|
|
103
108
|
};
|
|
104
109
|
}
|
|
105
110
|
|
|
@@ -641,6 +646,19 @@ async function handleOpenPath(targetPath, label) {
|
|
|
641
646
|
}
|
|
642
647
|
}
|
|
643
648
|
|
|
649
|
+
async function handleCloseApp() {
|
|
650
|
+
const confirmed = window.confirm("确定要关闭当前程序吗?");
|
|
651
|
+
if (!confirmed) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
try {
|
|
656
|
+
await bridge.closeApp();
|
|
657
|
+
} catch {
|
|
658
|
+
// 服务关闭过程会中断当前页面请求,这里不再额外提示。
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
644
662
|
function bindEvents() {
|
|
645
663
|
$("#authForm").addEventListener("submit", (event) => {
|
|
646
664
|
event.preventDefault();
|
|
@@ -688,6 +706,7 @@ function bindEvents() {
|
|
|
688
706
|
handleOpenPath(state.paths.endpointStorePath, " 连接数据文件");
|
|
689
707
|
}
|
|
690
708
|
});
|
|
709
|
+
$("#closeAppButton").addEventListener("click", handleCloseApp);
|
|
691
710
|
window.addEventListener("keydown", (event) => {
|
|
692
711
|
if (event.key === "Escape" && !$("#endpointModal").hidden) {
|
|
693
712
|
closeEndpointModal();
|
package/src/renderer/styles.css
CHANGED
|
@@ -101,27 +101,87 @@ code {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
.auth-card {
|
|
104
|
-
width: min(
|
|
104
|
+
width: min(980px, 100%);
|
|
105
105
|
min-height: auto;
|
|
106
|
-
padding:
|
|
106
|
+
padding: 22px;
|
|
107
107
|
border-radius: 36px;
|
|
108
|
-
display:
|
|
108
|
+
display: grid;
|
|
109
|
+
grid-template-columns: minmax(0, 1.04fr) minmax(360px, 0.96fr);
|
|
110
|
+
gap: 18px;
|
|
111
|
+
align-items: stretch;
|
|
109
112
|
background:
|
|
110
113
|
radial-gradient(circle at top left, rgba(216, 97, 53, 0.18), transparent 34%),
|
|
111
114
|
radial-gradient(circle at bottom right, rgba(46, 139, 139, 0.16), transparent 32%),
|
|
112
115
|
rgba(255, 249, 243, 0.9);
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
.auth-hero-card,
|
|
115
119
|
.auth-form-card {
|
|
116
120
|
display: grid;
|
|
117
121
|
align-content: center;
|
|
118
122
|
gap: 14px;
|
|
119
|
-
padding:
|
|
123
|
+
padding: 20px;
|
|
120
124
|
border-radius: 30px;
|
|
121
125
|
background: rgba(255, 252, 248, 0.82);
|
|
122
126
|
border: 1px solid rgba(47, 36, 30, 0.08);
|
|
123
127
|
}
|
|
124
128
|
|
|
129
|
+
.auth-hero-card {
|
|
130
|
+
background:
|
|
131
|
+
radial-gradient(circle at top left, rgba(216, 97, 53, 0.18), transparent 32%),
|
|
132
|
+
linear-gradient(165deg, rgba(255, 250, 245, 0.96), rgba(248, 239, 232, 0.9));
|
|
133
|
+
gap: 18px;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.auth-hero-title {
|
|
137
|
+
margin: 0;
|
|
138
|
+
font-size: clamp(2rem, 3.2vw, 3rem);
|
|
139
|
+
line-height: 1.02;
|
|
140
|
+
font-family: "Bahnschrift", "Microsoft YaHei UI", sans-serif;
|
|
141
|
+
letter-spacing: 0.01em;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.auth-hero-copy {
|
|
145
|
+
margin: 0;
|
|
146
|
+
max-width: 34rem;
|
|
147
|
+
color: var(--muted);
|
|
148
|
+
font-size: 0.98rem;
|
|
149
|
+
line-height: 1.75;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.auth-highlight-list {
|
|
153
|
+
display: grid;
|
|
154
|
+
gap: 12px;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.auth-highlight-item {
|
|
158
|
+
display: grid;
|
|
159
|
+
gap: 6px;
|
|
160
|
+
padding: 14px 16px;
|
|
161
|
+
border-radius: 22px;
|
|
162
|
+
border: 1px solid rgba(47, 36, 30, 0.08);
|
|
163
|
+
background: rgba(255, 255, 255, 0.6);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.auth-highlight-label {
|
|
167
|
+
color: var(--accent-deep);
|
|
168
|
+
font-size: 0.76rem;
|
|
169
|
+
font-weight: 700;
|
|
170
|
+
letter-spacing: 0.14em;
|
|
171
|
+
text-transform: uppercase;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.auth-highlight-item strong {
|
|
175
|
+
font-size: 1.08rem;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.auth-highlight-item p {
|
|
179
|
+
margin: 0;
|
|
180
|
+
color: var(--muted);
|
|
181
|
+
line-height: 1.55;
|
|
182
|
+
font-size: 0.9rem;
|
|
183
|
+
}
|
|
184
|
+
|
|
125
185
|
.auth-panel-header {
|
|
126
186
|
margin-bottom: 0;
|
|
127
187
|
}
|
|
@@ -130,6 +190,10 @@ code {
|
|
|
130
190
|
margin-top: 0;
|
|
131
191
|
}
|
|
132
192
|
|
|
193
|
+
.auth-form-card .panel-header {
|
|
194
|
+
margin-bottom: 4px;
|
|
195
|
+
}
|
|
196
|
+
|
|
133
197
|
.auth-form .field-hint,
|
|
134
198
|
.auth-status {
|
|
135
199
|
margin-top: 2px;
|
|
@@ -742,6 +806,11 @@ input[readonly] {
|
|
|
742
806
|
height: auto;
|
|
743
807
|
}
|
|
744
808
|
|
|
809
|
+
.auth-card {
|
|
810
|
+
width: min(720px, 100%);
|
|
811
|
+
grid-template-columns: 1fr;
|
|
812
|
+
}
|
|
813
|
+
|
|
745
814
|
.content-grid {
|
|
746
815
|
grid-template-columns: 1fr;
|
|
747
816
|
}
|
|
@@ -793,16 +862,40 @@ input[readonly] {
|
|
|
793
862
|
|
|
794
863
|
.auth-card {
|
|
795
864
|
width: 100%;
|
|
796
|
-
padding:
|
|
865
|
+
padding: 12px;
|
|
797
866
|
border-radius: 24px;
|
|
867
|
+
gap: 12px;
|
|
798
868
|
}
|
|
799
869
|
|
|
870
|
+
.auth-hero-card,
|
|
800
871
|
.auth-form-card,
|
|
801
872
|
.panel {
|
|
802
873
|
border-radius: 20px;
|
|
803
874
|
padding: 14px;
|
|
804
875
|
}
|
|
805
876
|
|
|
877
|
+
.auth-hero-card {
|
|
878
|
+
gap: 14px;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
.auth-hero-title {
|
|
882
|
+
font-size: 1.8rem;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
.auth-hero-copy {
|
|
886
|
+
font-size: 0.9rem;
|
|
887
|
+
line-height: 1.6;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
.auth-highlight-list {
|
|
891
|
+
gap: 8px;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
.auth-highlight-item {
|
|
895
|
+
padding: 12px;
|
|
896
|
+
border-radius: 18px;
|
|
897
|
+
}
|
|
898
|
+
|
|
806
899
|
.account-hint {
|
|
807
900
|
font-size: 0.82rem;
|
|
808
901
|
line-height: 1.4;
|
|
@@ -925,6 +1018,7 @@ input[readonly] {
|
|
|
925
1018
|
.auth-card {
|
|
926
1019
|
min-height: auto;
|
|
927
1020
|
padding: 16px;
|
|
1021
|
+
gap: 14px;
|
|
928
1022
|
}
|
|
929
1023
|
|
|
930
1024
|
.panel {
|
package/src/web/launcher.js
CHANGED
|
@@ -9,6 +9,7 @@ const serverEntryPath = path.join(__dirname, "server.js");
|
|
|
9
9
|
const port = Number(process.env.PORT || 3186);
|
|
10
10
|
const proxyPort = 3187;
|
|
11
11
|
const healthUrl = `http://127.0.0.1:${port}/api/health`;
|
|
12
|
+
const closeUrl = `http://127.0.0.1:${port}/api/app/close`;
|
|
12
13
|
const proxyHealthUrl = `http://127.0.0.1:${proxyPort}/__switcher/health`;
|
|
13
14
|
const consoleUrl = `http://localhost:${port}`;
|
|
14
15
|
|
|
@@ -41,6 +42,44 @@ function requestJson(url, timeoutMs = 1200) {
|
|
|
41
42
|
});
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
function postJson(url, timeoutMs = 2000) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const target = new URL(url);
|
|
48
|
+
const request = http.request(
|
|
49
|
+
{
|
|
50
|
+
hostname: target.hostname,
|
|
51
|
+
port: target.port,
|
|
52
|
+
path: target.pathname,
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: {
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
(response) => {
|
|
59
|
+
let data = "";
|
|
60
|
+
response.setEncoding("utf8");
|
|
61
|
+
response.on("data", (chunk) => {
|
|
62
|
+
data += chunk;
|
|
63
|
+
});
|
|
64
|
+
response.on("end", () => {
|
|
65
|
+
try {
|
|
66
|
+
resolve(JSON.parse(data));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
reject(error);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
request.setTimeout(timeoutMs, () => {
|
|
75
|
+
request.destroy(new Error("请求超时"));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
request.on("error", reject);
|
|
79
|
+
request.end("{}");
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
44
83
|
/**
|
|
45
84
|
* 检查本地网页服务是否已经可用。
|
|
46
85
|
*/
|
|
@@ -183,6 +222,65 @@ async function restartServer() {
|
|
|
183
222
|
return ensureServerRunning();
|
|
184
223
|
}
|
|
185
224
|
|
|
225
|
+
function killListeningPid(targetPort) {
|
|
226
|
+
return new Promise((resolve, reject) => {
|
|
227
|
+
exec(`cmd /c netstat -ano | findstr :${targetPort}`, (error, stdout) => {
|
|
228
|
+
if (error) {
|
|
229
|
+
resolve(false);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const lines = stdout
|
|
234
|
+
.split(/\r?\n/)
|
|
235
|
+
.map((line) => line.trim())
|
|
236
|
+
.filter(Boolean);
|
|
237
|
+
|
|
238
|
+
const pids = [...new Set(lines
|
|
239
|
+
.map((line) => line.split(/\s+/))
|
|
240
|
+
.filter((parts) => parts[3] === "LISTENING")
|
|
241
|
+
.map((parts) => Number(parts[4]))
|
|
242
|
+
.filter((pid) => Number.isFinite(pid)))];
|
|
243
|
+
|
|
244
|
+
if (!pids.length) {
|
|
245
|
+
resolve(false);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
exec(`cmd /c taskkill ${pids.map((pid) => `/PID ${pid}`).join(" ")} /F`, (killError) => {
|
|
250
|
+
if (killError) {
|
|
251
|
+
reject(killError);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
resolve(true);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function stopServer() {
|
|
262
|
+
if (await checkServerHealth()) {
|
|
263
|
+
try {
|
|
264
|
+
await postJson(closeUrl);
|
|
265
|
+
} catch {
|
|
266
|
+
// 服务正在退出时,可能来不及返回完整响应,这里继续走兜底检查。
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
await sleep(1000);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const webKilled = await killListeningPid(port);
|
|
273
|
+
const proxyKilled = await killListeningPid(proxyPort);
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
stopped: true,
|
|
277
|
+
webKilled,
|
|
278
|
+
proxyKilled,
|
|
279
|
+
port,
|
|
280
|
+
proxyPort,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
186
284
|
async function handleCommand(command) {
|
|
187
285
|
switch (command) {
|
|
188
286
|
case "ensure":
|
|
@@ -227,8 +325,13 @@ async function handleCommand(command) {
|
|
|
227
325
|
console.log(JSON.stringify({ ...result, restarted: true }, null, 2));
|
|
228
326
|
return;
|
|
229
327
|
}
|
|
328
|
+
case "stop": {
|
|
329
|
+
const result = await stopServer();
|
|
330
|
+
console.log(JSON.stringify(result, null, 2));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
230
333
|
default:
|
|
231
|
-
throw new Error("不支持的命令。可用命令:ensure、open、status、restart");
|
|
334
|
+
throw new Error("不支持的命令。可用命令:ensure、open、status、restart、stop");
|
|
232
335
|
}
|
|
233
336
|
}
|
|
234
337
|
|
|
@@ -245,6 +348,7 @@ module.exports = {
|
|
|
245
348
|
ensureServerRunning,
|
|
246
349
|
handleCommand,
|
|
247
350
|
restartServer,
|
|
351
|
+
stopServer,
|
|
248
352
|
openBrowser,
|
|
249
353
|
runtimeDir,
|
|
250
354
|
consoleUrl,
|
package/src/web/proxy-server.js
CHANGED
|
@@ -2,6 +2,147 @@ const http = require("node:http");
|
|
|
2
2
|
const https = require("node:https");
|
|
3
3
|
const profileManager = require("../main/profile-manager");
|
|
4
4
|
|
|
5
|
+
const httpAgent = new http.Agent({
|
|
6
|
+
keepAlive: true,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const httpsAgent = new https.Agent({
|
|
10
|
+
keepAlive: true,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function sleep(ms) {
|
|
14
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isRetryableTlsError(error) {
|
|
18
|
+
const code = String(error?.code || "").trim().toUpperCase();
|
|
19
|
+
const message = String(error?.message || "");
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
code === "ECONNRESET" ||
|
|
23
|
+
code === "ETIMEDOUT" ||
|
|
24
|
+
code === "EPIPE" ||
|
|
25
|
+
code === "EPROTO" ||
|
|
26
|
+
message.includes("secure TLS connection was established") ||
|
|
27
|
+
message.includes("socket hang up")
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readRequestBody(req) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const chunks = [];
|
|
34
|
+
|
|
35
|
+
req.on("data", (chunk) => {
|
|
36
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
req.on("end", () => {
|
|
40
|
+
resolve(chunks.length ? Buffer.concat(chunks) : Buffer.alloc(0));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
req.on("error", reject);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sendUpstreamRequest({
|
|
48
|
+
upstreamUrl,
|
|
49
|
+
requestModule,
|
|
50
|
+
headers,
|
|
51
|
+
method,
|
|
52
|
+
bodyBuffer,
|
|
53
|
+
}) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const upstreamRequest = requestModule.request(
|
|
56
|
+
upstreamUrl,
|
|
57
|
+
{
|
|
58
|
+
method,
|
|
59
|
+
headers,
|
|
60
|
+
agent: upstreamUrl.protocol === "https:" ? httpsAgent : httpAgent,
|
|
61
|
+
},
|
|
62
|
+
(upstreamResponse) => {
|
|
63
|
+
resolve(upstreamResponse);
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
upstreamRequest.setTimeout(12000, () => {
|
|
68
|
+
upstreamRequest.destroy(new Error("上游请求超时。"));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
upstreamRequest.on("error", reject);
|
|
72
|
+
|
|
73
|
+
if (bodyBuffer?.length) {
|
|
74
|
+
upstreamRequest.end(bodyBuffer);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
upstreamRequest.end();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function forwardWithRetry({
|
|
83
|
+
upstreamUrl,
|
|
84
|
+
requestModule,
|
|
85
|
+
headers,
|
|
86
|
+
method,
|
|
87
|
+
bodyBuffer,
|
|
88
|
+
retryCount = 2,
|
|
89
|
+
}) {
|
|
90
|
+
let lastError = null;
|
|
91
|
+
|
|
92
|
+
for (let attempt = 0; attempt <= retryCount; attempt += 1) {
|
|
93
|
+
try {
|
|
94
|
+
return await sendUpstreamRequest({
|
|
95
|
+
upstreamUrl,
|
|
96
|
+
requestModule,
|
|
97
|
+
headers,
|
|
98
|
+
method,
|
|
99
|
+
bodyBuffer,
|
|
100
|
+
});
|
|
101
|
+
} catch (error) {
|
|
102
|
+
lastError = error;
|
|
103
|
+
if (attempt >= retryCount || !isRetryableTlsError(error)) {
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await sleep(220 * (attempt + 1));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
throw lastError || new Error("代理转发失败。");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function warmUpCurrentTarget() {
|
|
115
|
+
const target = await profileManager.getProxyTarget();
|
|
116
|
+
const upstreamUrl = new URL(target.url);
|
|
117
|
+
const requestModule = upstreamUrl.protocol === "https:" ? https : http;
|
|
118
|
+
|
|
119
|
+
const warmUrl = new URL("/", upstreamUrl);
|
|
120
|
+
|
|
121
|
+
await new Promise((resolve, reject) => {
|
|
122
|
+
const warmRequest = requestModule.request(
|
|
123
|
+
warmUrl,
|
|
124
|
+
{
|
|
125
|
+
method: "HEAD",
|
|
126
|
+
headers: {
|
|
127
|
+
authorization: `Bearer ${target.key}`,
|
|
128
|
+
host: warmUrl.host,
|
|
129
|
+
},
|
|
130
|
+
agent: upstreamUrl.protocol === "https:" ? httpsAgent : httpAgent,
|
|
131
|
+
},
|
|
132
|
+
(response) => {
|
|
133
|
+
response.resume();
|
|
134
|
+
resolve(response.statusCode || 200);
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
warmRequest.setTimeout(6000, () => {
|
|
139
|
+
warmRequest.destroy(new Error("预热请求超时。"));
|
|
140
|
+
});
|
|
141
|
+
warmRequest.on("error", reject);
|
|
142
|
+
warmRequest.end();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
5
146
|
function createProxyServer() {
|
|
6
147
|
return http.createServer(async (req, res) => {
|
|
7
148
|
try {
|
|
@@ -16,42 +157,28 @@ function createProxyServer() {
|
|
|
16
157
|
const requestPath = req.url && req.url.startsWith("/") ? req.url : `/${req.url || ""}`;
|
|
17
158
|
const upstreamUrl = new URL(requestPath, upstreamBase);
|
|
18
159
|
const requestModule = upstreamUrl.protocol === "https:" ? https : http;
|
|
160
|
+
const bodyBuffer = await readRequestBody(req);
|
|
19
161
|
const headers = {
|
|
20
162
|
...req.headers,
|
|
21
163
|
host: upstreamUrl.host,
|
|
22
164
|
authorization: `Bearer ${target.key}`,
|
|
165
|
+
"content-length": String(bodyBuffer.length),
|
|
23
166
|
};
|
|
24
167
|
|
|
25
168
|
delete headers.connection;
|
|
26
|
-
|
|
27
|
-
const upstreamRequest = requestModule.request(
|
|
169
|
+
const upstreamResponse = await forwardWithRetry({
|
|
28
170
|
upstreamUrl,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
(upstreamResponse) => {
|
|
34
|
-
res.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);
|
|
35
|
-
upstreamResponse.pipe(res);
|
|
36
|
-
},
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
upstreamRequest.on("error", (error) => {
|
|
40
|
-
if (!res.headersSent) {
|
|
41
|
-
res.writeHead(502, { "Content-Type": "application/json; charset=utf-8" });
|
|
42
|
-
}
|
|
43
|
-
res.end(
|
|
44
|
-
JSON.stringify({
|
|
45
|
-
ok: false,
|
|
46
|
-
error: `代理转发失败:${error.message}`,
|
|
47
|
-
}),
|
|
48
|
-
);
|
|
171
|
+
requestModule,
|
|
172
|
+
headers,
|
|
173
|
+
method: req.method,
|
|
174
|
+
bodyBuffer,
|
|
49
175
|
});
|
|
50
176
|
|
|
51
|
-
|
|
177
|
+
res.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);
|
|
178
|
+
upstreamResponse.pipe(res);
|
|
52
179
|
} catch (error) {
|
|
53
180
|
if (!res.headersSent) {
|
|
54
|
-
res.writeHead(
|
|
181
|
+
res.writeHead(502, { "Content-Type": "application/json; charset=utf-8" });
|
|
55
182
|
}
|
|
56
183
|
|
|
57
184
|
res.end(
|
|
@@ -66,4 +193,5 @@ function createProxyServer() {
|
|
|
66
193
|
|
|
67
194
|
module.exports = {
|
|
68
195
|
createProxyServer,
|
|
196
|
+
warmUpCurrentTarget,
|
|
69
197
|
};
|
package/src/web/server.js
CHANGED
|
@@ -3,7 +3,7 @@ const { exec } = require("node:child_process");
|
|
|
3
3
|
const express = require("express");
|
|
4
4
|
const profileManager = require("../main/profile-manager");
|
|
5
5
|
const cloudSyncClient = require("../main/cloud-sync-client");
|
|
6
|
-
const { createProxyServer } = require("./proxy-server");
|
|
6
|
+
const { createProxyServer, warmUpCurrentTarget } = require("./proxy-server");
|
|
7
7
|
|
|
8
8
|
function wrapAsync(handler) {
|
|
9
9
|
return async (req, res) => {
|
|
@@ -166,6 +166,19 @@ function createApp() {
|
|
|
166
166
|
}),
|
|
167
167
|
);
|
|
168
168
|
|
|
169
|
+
app.post(
|
|
170
|
+
"/api/app/close",
|
|
171
|
+
wrapAsync(async () => {
|
|
172
|
+
setTimeout(() => {
|
|
173
|
+
process.emit("codex-switcher:shutdown");
|
|
174
|
+
}, 80);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
closing: true,
|
|
178
|
+
};
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
|
|
169
182
|
app.post(
|
|
170
183
|
"/api/open-path",
|
|
171
184
|
wrapAsync(async (req) => {
|
|
@@ -213,8 +226,11 @@ function startServer(options = {}) {
|
|
|
213
226
|
});
|
|
214
227
|
|
|
215
228
|
proxyServer.listen(proxyPort);
|
|
229
|
+
warmUpCurrentTarget().catch(() => {
|
|
230
|
+
// 预热失败不阻断服务启动,真正请求时仍会走自动重试。
|
|
231
|
+
});
|
|
216
232
|
|
|
217
|
-
|
|
233
|
+
const controller = {
|
|
218
234
|
webServer,
|
|
219
235
|
proxyServer,
|
|
220
236
|
close(callback) {
|
|
@@ -243,6 +259,15 @@ function startServer(options = {}) {
|
|
|
243
259
|
proxyServer.close((error) => done(error));
|
|
244
260
|
},
|
|
245
261
|
};
|
|
262
|
+
|
|
263
|
+
process.removeAllListeners("codex-switcher:shutdown");
|
|
264
|
+
process.on("codex-switcher:shutdown", () => {
|
|
265
|
+
controller.close(() => {
|
|
266
|
+
process.exit(0);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return controller;
|
|
246
271
|
}
|
|
247
272
|
|
|
248
273
|
if (require.main === module) {
|