codex-endpoint-switcher 1.0.0 → 1.0.1

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 CHANGED
@@ -68,3 +68,42 @@ codex-switcher remove-access
68
68
  ```text
69
69
  http://localhost:3186
70
70
  ```
71
+
72
+ ## 更新 npm 包
73
+
74
+ 先进入项目目录:
75
+
76
+ ```bash
77
+ cd C:\Users\33825\Desktop\codex-profile-desktop
78
+ ```
79
+
80
+ 发布前先检查这次会打进 npm 包里的文件:
81
+
82
+ ```bash
83
+ npm run release:check
84
+ ```
85
+
86
+ 如果只是小修复,直接发补丁版本:
87
+
88
+ ```bash
89
+ npm run release:patch
90
+ ```
91
+
92
+ 如果是新增功能但兼容旧版:
93
+
94
+ ```bash
95
+ npm run release:minor
96
+ ```
97
+
98
+ 如果有破坏性改动:
99
+
100
+ ```bash
101
+ npm run release:major
102
+ ```
103
+
104
+ 说明:
105
+
106
+ - `release:patch` 会自动把版本从 `1.0.0` 升到 `1.0.1` 这一类补丁版本,然后执行 `npm publish`
107
+ - `release:minor` 会自动把版本从 `1.0.0` 升到 `1.1.0` 这一类功能版本,然后执行 `npm publish`
108
+ - `release:major` 会自动把版本从 `1.0.0` 升到 `2.0.0` 这一类大版本,然后执行 `npm publish`
109
+ - 如果 npm 开启了 2FA,发布时仍然会要求输入验证码
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-endpoint-switcher",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "用于切换 Codex URL 和 Key 的本地网页控制台与 npm CLI",
5
5
  "main": "src/main/main.js",
6
6
  "bin": {
@@ -23,6 +23,10 @@
23
23
  "dev:web": "node src/web/server.js",
24
24
  "cli": "node bin/codex-switcher.js",
25
25
  "pack:npm": "npm pack",
26
+ "release:check": "npm pack --dry-run",
27
+ "release:patch": "npm version patch --no-git-tag-version && npm publish",
28
+ "release:minor": "npm version minor --no-git-tag-version && npm publish",
29
+ "release:major": "npm version major --no-git-tag-version && npm publish",
26
30
  "build:portable": "electron-builder --win portable",
27
31
  "build:installer": "electron-builder --win nsis"
28
32
  },
package/src/main/main.js CHANGED
@@ -46,6 +46,10 @@ function registerHandlers() {
46
46
  return profileManager.switchEndpoint(payload.id);
47
47
  });
48
48
 
49
+ ipcMain.handle("proxy:enable", async () => {
50
+ return profileManager.enableProxyMode();
51
+ });
52
+
49
53
  ipcMain.handle("paths:get", async () => {
50
54
  return profileManager.getManagedPaths();
51
55
  });
@@ -19,6 +19,9 @@ contextBridge.exposeInMainWorld("codexDesktop", {
19
19
  switchEndpoint(payload) {
20
20
  return ipcRenderer.invoke("endpoints:switch", payload);
21
21
  },
22
+ enableProxyMode() {
23
+ return ipcRenderer.invoke("proxy:enable");
24
+ },
22
25
  getPaths() {
23
26
  return ipcRenderer.invoke("paths:get");
24
27
  },
@@ -8,6 +8,9 @@ const backupDir = path.join(codexRoot, "backup");
8
8
  const mainConfigPath = path.join(codexRoot, "config.toml");
9
9
  const authConfigPath = path.join(codexRoot, "auth.json");
10
10
  const endpointStorePath = path.join(codexRoot, "endpoint-presets.json");
11
+ const endpointStatePath = path.join(codexRoot, "endpoint-switcher-state.json");
12
+ const proxyPort = 3187;
13
+ const proxyBaseUrl = `http://127.0.0.1:${proxyPort}/`;
11
14
 
12
15
  function escapeRegExp(value) {
13
16
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -206,20 +209,12 @@ function updateProviderBaseUrl(content, provider, nextUrl) {
206
209
  return output.join(newline);
207
210
  }
208
211
 
209
- function buildEndpointResponse(endpoint, activeId) {
210
- return {
211
- id: endpoint.id,
212
- note: endpoint.note,
213
- url: endpoint.url,
214
- key: endpoint.key,
215
- maskedKey: maskKey(endpoint.key),
216
- createdAt: endpoint.createdAt,
217
- updatedAt: endpoint.updatedAt,
218
- isActive: endpoint.id === activeId,
219
- };
212
+ function isProxyBaseUrl(url) {
213
+ const normalized = String(url || "").replace(/\/+$/, "");
214
+ return normalized === proxyBaseUrl.replace(/\/+$/, "");
220
215
  }
221
216
 
222
- async function ensureEndpointStore() {
217
+ async function ensureStorage() {
223
218
  await ensureFileExists(mainConfigPath, `未找到 Codex 主配置文件:${mainConfigPath}`);
224
219
  await ensureDirectory(backupDir);
225
220
 
@@ -228,10 +223,19 @@ async function ensureEndpointStore() {
228
223
  } catch {
229
224
  await writeJsonFile(endpointStorePath, []);
230
225
  }
226
+
227
+ try {
228
+ await fs.access(endpointStatePath);
229
+ } catch {
230
+ await writeJsonFile(endpointStatePath, {
231
+ activeEndpointId: "",
232
+ proxyModeEnabled: false,
233
+ });
234
+ }
231
235
  }
232
236
 
233
237
  async function readEndpointStore() {
234
- await ensureEndpointStore();
238
+ await ensureStorage();
235
239
  const data = await readJsonFile(endpointStorePath, []);
236
240
  return Array.isArray(data) ? data : [];
237
241
  }
@@ -240,19 +244,41 @@ async function writeEndpointStore(endpoints) {
240
244
  await writeJsonFile(endpointStorePath, endpoints);
241
245
  }
242
246
 
247
+ async function readEndpointState() {
248
+ await ensureStorage();
249
+ const state = await readJsonFile(endpointStatePath, {
250
+ activeEndpointId: "",
251
+ proxyModeEnabled: false,
252
+ });
253
+
254
+ return {
255
+ activeEndpointId: String(state.activeEndpointId || "").trim(),
256
+ proxyModeEnabled: Boolean(state.proxyModeEnabled),
257
+ };
258
+ }
259
+
260
+ async function writeEndpointState(state) {
261
+ await writeJsonFile(endpointStatePath, {
262
+ activeEndpointId: String(state.activeEndpointId || "").trim(),
263
+ proxyModeEnabled: Boolean(state.proxyModeEnabled),
264
+ });
265
+ }
266
+
243
267
  async function readCurrentConfigAndAuth() {
244
- await ensureEndpointStore();
268
+ await ensureStorage();
245
269
  const configContent = await readTextFile(mainConfigPath);
246
270
  const authConfig = await readJsonFile(authConfigPath, {});
247
271
  const provider = readTomlScalar(configContent, "model_provider");
272
+ const baseUrl = readProviderBaseUrl(configContent, provider);
248
273
 
249
274
  return {
250
275
  configContent,
251
276
  authConfig,
252
277
  provider,
253
278
  model: readTomlScalar(configContent, "model"),
254
- baseUrl: readProviderBaseUrl(configContent, provider),
279
+ baseUrl,
255
280
  apiKey: String(authConfig.OPENAI_API_KEY || "").trim(),
281
+ proxyModeEnabled: isProxyBaseUrl(baseUrl),
256
282
  };
257
283
  }
258
284
 
@@ -276,45 +302,78 @@ async function backupCurrentState() {
276
302
  };
277
303
  }
278
304
 
279
- async function getCurrentEndpointSummary() {
280
- const [currentState, endpoints] = await Promise.all([
305
+ function buildEndpointResponse(endpoint, activeId) {
306
+ return {
307
+ id: endpoint.id,
308
+ note: endpoint.note,
309
+ url: endpoint.url,
310
+ key: endpoint.key,
311
+ maskedKey: maskKey(endpoint.key),
312
+ createdAt: endpoint.createdAt,
313
+ updatedAt: endpoint.updatedAt,
314
+ isActive: endpoint.id === activeId,
315
+ };
316
+ }
317
+
318
+ async function resolveActiveEndpointContext() {
319
+ const [currentState, endpoints, state] = await Promise.all([
281
320
  readCurrentConfigAndAuth(),
282
321
  readEndpointStore(),
322
+ readEndpointState(),
283
323
  ]);
284
324
 
285
- const activeEndpoint = endpoints.find(
286
- (item) => item.url === currentState.baseUrl && item.key === currentState.apiKey,
287
- );
325
+ let activeEndpoint = null;
326
+
327
+ if (currentState.proxyModeEnabled && state.activeEndpointId) {
328
+ activeEndpoint = endpoints.find((item) => item.id === state.activeEndpointId) || null;
329
+ }
330
+
331
+ if (!activeEndpoint) {
332
+ activeEndpoint =
333
+ endpoints.find(
334
+ (item) => item.url === currentState.baseUrl && item.key === currentState.apiKey,
335
+ ) || null;
336
+ }
288
337
 
289
338
  return {
290
- model: currentState.model,
291
- provider: currentState.provider,
292
- currentUrl: currentState.baseUrl,
293
- currentKeyMasked: maskKey(currentState.apiKey),
294
- currentNote: activeEndpoint ? activeEndpoint.note : "未命名连接",
339
+ currentState,
340
+ endpoints,
341
+ state,
342
+ activeEndpoint,
295
343
  activeEndpointId: activeEndpoint ? activeEndpoint.id : "",
344
+ };
345
+ }
346
+
347
+ async function getCurrentEndpointSummary() {
348
+ const context = await resolveActiveEndpointContext();
349
+ const currentNote = context.activeEndpoint ? context.activeEndpoint.note : "未命名连接";
350
+
351
+ return {
352
+ model: context.currentState.model,
353
+ provider: context.currentState.provider,
354
+ currentUrl: context.activeEndpoint ? context.activeEndpoint.url : context.currentState.baseUrl,
355
+ currentKeyMasked: context.activeEndpoint
356
+ ? maskKey(context.activeEndpoint.key)
357
+ : maskKey(context.currentState.apiKey),
358
+ currentNote,
359
+ activeEndpointId: context.activeEndpointId,
296
360
  endpointStorePath,
361
+ endpointStatePath,
297
362
  authConfigPath,
298
363
  mainConfigPath,
299
- totalEndpoints: endpoints.length,
364
+ totalEndpoints: context.endpoints.length,
365
+ proxyModeEnabled: context.currentState.proxyModeEnabled,
366
+ proxyBaseUrl,
367
+ hotReloadReady: context.currentState.proxyModeEnabled,
300
368
  };
301
369
  }
302
370
 
303
371
  async function listEndpoints() {
304
- const [currentState, endpoints] = await Promise.all([
305
- readCurrentConfigAndAuth(),
306
- readEndpointStore(),
307
- ]);
308
-
309
- const activeEndpoint = endpoints.find(
310
- (item) => item.url === currentState.baseUrl && item.key === currentState.apiKey,
311
- );
312
- const activeId = activeEndpoint ? activeEndpoint.id : "";
313
-
314
- return endpoints
372
+ const context = await resolveActiveEndpointContext();
373
+ return context.endpoints
315
374
  .slice()
316
375
  .sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)))
317
- .map((endpoint) => buildEndpointResponse(endpoint, activeId));
376
+ .map((endpoint) => buildEndpointResponse(endpoint, context.activeEndpointId));
318
377
  }
319
378
 
320
379
  async function createEndpoint(payload) {
@@ -378,6 +437,14 @@ async function deleteEndpoint(id) {
378
437
  const [removed] = endpoints.splice(index, 1);
379
438
  await writeEndpointStore(endpoints);
380
439
 
440
+ const state = await readEndpointState();
441
+ if (state.activeEndpointId === removed.id) {
442
+ await writeEndpointState({
443
+ ...state,
444
+ activeEndpointId: "",
445
+ });
446
+ }
447
+
381
448
  return {
382
449
  id: removed.id,
383
450
  note: removed.note,
@@ -397,24 +464,121 @@ async function switchEndpoint(id) {
397
464
  throw new Error("未找到要切换的端点。");
398
465
  }
399
466
 
400
- const { configContent, authConfig, provider } = await readCurrentConfigAndAuth();
401
- if (!provider) {
467
+ const currentState = await readCurrentConfigAndAuth();
468
+
469
+ if (currentState.proxyModeEnabled) {
470
+ await writeEndpointState({
471
+ activeEndpointId: target.id,
472
+ proxyModeEnabled: true,
473
+ });
474
+
475
+ return {
476
+ ...(await getCurrentEndpointSummary()),
477
+ switchedViaProxy: true,
478
+ nextRequestHotReloaded: true,
479
+ };
480
+ }
481
+
482
+ if (!currentState.provider) {
402
483
  throw new Error("当前 config.toml 未找到 model_provider。");
403
484
  }
404
485
 
405
- const nextConfigContent = updateProviderBaseUrl(configContent, provider, target.url);
486
+ const nextConfigContent = updateProviderBaseUrl(
487
+ currentState.configContent,
488
+ currentState.provider,
489
+ target.url,
490
+ );
406
491
  const nextAuthConfig = {
407
- ...authConfig,
492
+ ...currentState.authConfig,
408
493
  OPENAI_API_KEY: target.key,
409
494
  };
410
495
 
411
496
  const backupPaths = await backupCurrentState();
412
497
  await writeTextFile(mainConfigPath, nextConfigContent);
413
498
  await writeJsonFile(authConfigPath, nextAuthConfig);
499
+ await writeEndpointState({
500
+ activeEndpointId: target.id,
501
+ proxyModeEnabled: false,
502
+ });
414
503
 
415
504
  return {
416
505
  ...backupPaths,
417
506
  ...(await getCurrentEndpointSummary()),
507
+ switchedViaProxy: false,
508
+ nextRequestHotReloaded: false,
509
+ };
510
+ }
511
+
512
+ async function enableProxyMode() {
513
+ const [currentState, endpoints, state] = await Promise.all([
514
+ readCurrentConfigAndAuth(),
515
+ readEndpointStore(),
516
+ readEndpointState(),
517
+ ]);
518
+
519
+ if (!currentState.provider) {
520
+ throw new Error("当前 config.toml 未找到 model_provider。");
521
+ }
522
+
523
+ let workingEndpoints = endpoints.slice();
524
+ let activeEndpoint =
525
+ workingEndpoints.find(
526
+ (item) => item.url === currentState.baseUrl && item.key === currentState.apiKey,
527
+ ) || null;
528
+
529
+ if (!activeEndpoint && !currentState.proxyModeEnabled) {
530
+ const now = new Date().toISOString();
531
+ activeEndpoint = {
532
+ id: randomUUID(),
533
+ note: "当前连接",
534
+ url: currentState.baseUrl,
535
+ key: currentState.apiKey,
536
+ createdAt: now,
537
+ updatedAt: now,
538
+ };
539
+ workingEndpoints.push(activeEndpoint);
540
+ await writeEndpointStore(workingEndpoints);
541
+ }
542
+
543
+ const nextActiveId = activeEndpoint
544
+ ? activeEndpoint.id
545
+ : state.activeEndpointId || (workingEndpoints[0] ? workingEndpoints[0].id : "");
546
+
547
+ if (!nextActiveId) {
548
+ throw new Error("请先新增至少一条连接,再开启热更新模式。");
549
+ }
550
+
551
+ if (!currentState.proxyModeEnabled) {
552
+ const backupPaths = await backupCurrentState();
553
+ const nextConfigContent = updateProviderBaseUrl(
554
+ currentState.configContent,
555
+ currentState.provider,
556
+ proxyBaseUrl,
557
+ );
558
+ await writeTextFile(mainConfigPath, nextConfigContent);
559
+ await writeEndpointState({
560
+ activeEndpointId: nextActiveId,
561
+ proxyModeEnabled: true,
562
+ });
563
+
564
+ return {
565
+ ...backupPaths,
566
+ ...(await getCurrentEndpointSummary()),
567
+ oneTimeRestartRequired: true,
568
+ message:
569
+ "已切到本地代理模式。已经打开着的旧 Codex 会话需要手动重开一次;之后同一会话内即可热更新。",
570
+ };
571
+ }
572
+
573
+ await writeEndpointState({
574
+ activeEndpointId: nextActiveId,
575
+ proxyModeEnabled: true,
576
+ });
577
+
578
+ return {
579
+ ...(await getCurrentEndpointSummary()),
580
+ oneTimeRestartRequired: false,
581
+ message: "热更新模式已经开启。",
418
582
  };
419
583
  }
420
584
 
@@ -425,15 +589,39 @@ function getManagedPaths() {
425
589
  mainConfigPath,
426
590
  authConfigPath,
427
591
  endpointStorePath,
592
+ endpointStatePath,
593
+ proxyBaseUrl,
594
+ proxyPort,
595
+ };
596
+ }
597
+
598
+ async function getProxyTarget() {
599
+ const context = await resolveActiveEndpointContext();
600
+ if (!context.activeEndpoint) {
601
+ throw new Error("当前没有激活的连接,无法进行代理转发。");
602
+ }
603
+
604
+ return {
605
+ id: context.activeEndpoint.id,
606
+ note: context.activeEndpoint.note,
607
+ url: context.activeEndpoint.url,
608
+ key: context.activeEndpoint.key,
609
+ proxyModeEnabled: context.currentState.proxyModeEnabled,
610
+ proxyBaseUrl,
611
+ proxyPort,
428
612
  };
429
613
  }
430
614
 
431
615
  module.exports = {
432
616
  createEndpoint,
433
617
  deleteEndpoint,
618
+ enableProxyMode,
434
619
  getCurrentEndpointSummary,
435
620
  getManagedPaths,
621
+ getProxyTarget,
436
622
  listEndpoints,
623
+ proxyBaseUrl,
624
+ proxyPort,
437
625
  switchEndpoint,
438
626
  updateEndpoint,
439
627
  };
@@ -64,6 +64,18 @@
64
64
  <span class="metric-label">模型</span>
65
65
  <strong id="currentModel">-</strong>
66
66
  </article>
67
+ <article class="metric-card metric-card-wide">
68
+ <span class="metric-label">热更新模式</span>
69
+ <strong id="proxyModeStatus">未开启</strong>
70
+ <p class="field-hint" id="proxyModeHint">
71
+ 当前还是直连模式。开启后,后续同一 Codex 会话可在下一次请求时热更新到新 URL/Key。
72
+ </p>
73
+ <div class="action-row metric-actions">
74
+ <button id="enableProxyModeButton" type="button" class="secondary-button">
75
+ 开启热更新模式
76
+ </button>
77
+ </div>
78
+ </article>
67
79
  </div>
68
80
  </section>
69
81
 
@@ -51,6 +51,11 @@ function createWebBridge() {
51
51
  body: JSON.stringify(payload),
52
52
  });
53
53
  },
54
+ enableProxyMode() {
55
+ return request("/api/proxy/enable", {
56
+ method: "POST",
57
+ });
58
+ },
54
59
  getPaths() {
55
60
  return request("/api/paths");
56
61
  },
@@ -120,8 +125,18 @@ function renderCurrent() {
120
125
  $("#currentBaseUrl").textContent = current.currentUrl || "-";
121
126
  $("#currentKeyMasked").textContent = current.currentKeyMasked || "-";
122
127
  $("#currentModel").textContent = current.model || "-";
128
+ $("#proxyModeStatus").textContent = current.proxyModeEnabled
129
+ ? `已开启,固定代理:${current.proxyBaseUrl}`
130
+ : "未开启";
131
+ $("#proxyModeHint").textContent = current.proxyModeEnabled
132
+ ? "当前 Codex 只要已经接入本地代理,后续同一会话内切换连接会在下一次请求时热更新。"
133
+ : "当前还是直连模式。开启后需要把已打开的旧 Codex 会话重开一次;之后同一会话即可热更新。";
134
+ $("#enableProxyModeButton").disabled = current.proxyModeEnabled;
135
+ $("#enableProxyModeButton").textContent = current.proxyModeEnabled
136
+ ? "热更新模式已开启"
137
+ : "开启热更新模式";
123
138
  $("#heroBadge").textContent = current.currentNote
124
- ? `当前连接:${current.currentNote}`
139
+ ? `${current.proxyModeEnabled ? "热更新中" : "当前连接"}:${current.currentNote}`
125
140
  : "当前未匹配到已保存连接";
126
141
  }
127
142
 
@@ -241,10 +256,14 @@ async function handleSwitchEndpoint(id, note) {
241
256
  setStatus(`正在切换到:${note} ...`, "info");
242
257
  const result = await bridge.switchEndpoint({ id });
243
258
  await refreshDashboard(false);
244
- setStatus(
245
- `已切换到 ${note}。已备份 config.toml 和 auth.json,下一条 codex 命令直接生效。`,
246
- "success",
247
- );
259
+ if (result.switchedViaProxy) {
260
+ setStatus(`已切换到 ${note}。当前会话下一次请求会直接走新的 URL/Key。`, "success");
261
+ } else {
262
+ setStatus(
263
+ `已切换到 ${note}。当前还是直连模式,只有新开的 Codex 会话会立即使用新配置。`,
264
+ "success",
265
+ );
266
+ }
248
267
  return result;
249
268
  } catch (error) {
250
269
  setStatus(`切换失败:${error.message}`, "error");
@@ -252,6 +271,24 @@ async function handleSwitchEndpoint(id, note) {
252
271
  }
253
272
  }
254
273
 
274
+ async function handleEnableProxyMode() {
275
+ try {
276
+ setStatus("正在切到热更新代理模式 ...", "info");
277
+ const result = await bridge.enableProxyMode();
278
+ await refreshDashboard(false);
279
+ if (result.oneTimeRestartRequired) {
280
+ setStatus(
281
+ "热更新模式已开启。请把当前已经打开着的旧 Codex 会话重开一次;之后同一会话内即可热更新。",
282
+ "success",
283
+ );
284
+ } else {
285
+ setStatus("热更新模式已开启。当前会话后续请求可直接热更新到新连接。", "success");
286
+ }
287
+ } catch (error) {
288
+ setStatus(`开启热更新模式失败:${error.message}`, "error");
289
+ }
290
+ }
291
+
255
292
  async function handleSubmitEndpoint(event) {
256
293
  event.preventDefault();
257
294
  const id = $("#endpointId").value.trim();
@@ -320,6 +357,7 @@ function bindEvents() {
320
357
  resetForm();
321
358
  setStatus("已清空表单。", "info");
322
359
  });
360
+ $("#enableProxyModeButton").addEventListener("click", handleEnableProxyMode);
323
361
  $("#openCodexRootButton").addEventListener("click", () => {
324
362
  if (state.paths?.codexRoot) {
325
363
  handleOpenPath(state.paths.codexRoot, " Codex 目录");
@@ -300,6 +300,10 @@ input[readonly] {
300
300
  gap: 10px;
301
301
  }
302
302
 
303
+ .metric-actions {
304
+ margin-top: 12px;
305
+ }
306
+
303
307
  .primary-button,
304
308
  .secondary-button,
305
309
  .ghost-button {
@@ -7,7 +7,9 @@ const projectRoot = path.resolve(__dirname, "../..");
7
7
  const runtimeDir = path.join(projectRoot, "runtime");
8
8
  const serverEntryPath = path.join(__dirname, "server.js");
9
9
  const port = Number(process.env.PORT || 3186);
10
+ const proxyPort = 3187;
10
11
  const healthUrl = `http://127.0.0.1:${port}/api/health`;
12
+ const proxyHealthUrl = `http://127.0.0.1:${proxyPort}/__switcher/health`;
11
13
  const consoleUrl = `http://localhost:${port}`;
12
14
 
13
15
  function ensureRuntimeDir() {
@@ -45,7 +47,8 @@ function requestJson(url, timeoutMs = 1200) {
45
47
  async function checkServerHealth() {
46
48
  try {
47
49
  const payload = await requestJson(healthUrl);
48
- return Boolean(payload && payload.ok);
50
+ const proxyPayload = await requestJson(proxyHealthUrl);
51
+ return Boolean(payload && payload.ok && proxyPayload && proxyPayload.ok);
49
52
  } catch {
50
53
  return false;
51
54
  }
@@ -211,6 +214,7 @@ async function handleCommand(command) {
211
214
  running,
212
215
  url: consoleUrl,
213
216
  port,
217
+ proxyPort,
214
218
  },
215
219
  null,
216
220
  2,
@@ -245,4 +249,5 @@ module.exports = {
245
249
  runtimeDir,
246
250
  consoleUrl,
247
251
  port,
252
+ proxyPort,
248
253
  };
@@ -0,0 +1,69 @@
1
+ const http = require("node:http");
2
+ const https = require("node:https");
3
+ const profileManager = require("../main/profile-manager");
4
+
5
+ function createProxyServer() {
6
+ return http.createServer(async (req, res) => {
7
+ try {
8
+ if (req.url === "/__switcher/health") {
9
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
10
+ res.end(JSON.stringify({ ok: true, proxyBaseUrl: profileManager.proxyBaseUrl }));
11
+ return;
12
+ }
13
+
14
+ const target = await profileManager.getProxyTarget();
15
+ const upstreamBase = new URL(target.url);
16
+ const requestPath = req.url && req.url.startsWith("/") ? req.url : `/${req.url || ""}`;
17
+ const upstreamUrl = new URL(requestPath, upstreamBase);
18
+ const requestModule = upstreamUrl.protocol === "https:" ? https : http;
19
+ const headers = {
20
+ ...req.headers,
21
+ host: upstreamUrl.host,
22
+ authorization: `Bearer ${target.key}`,
23
+ };
24
+
25
+ delete headers.connection;
26
+
27
+ const upstreamRequest = requestModule.request(
28
+ upstreamUrl,
29
+ {
30
+ method: req.method,
31
+ headers,
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
+ );
49
+ });
50
+
51
+ req.pipe(upstreamRequest);
52
+ } catch (error) {
53
+ if (!res.headersSent) {
54
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
55
+ }
56
+
57
+ res.end(
58
+ JSON.stringify({
59
+ ok: false,
60
+ error: error instanceof Error ? error.message : String(error),
61
+ }),
62
+ );
63
+ }
64
+ });
65
+ }
66
+
67
+ module.exports = {
68
+ createProxyServer,
69
+ };
package/src/web/server.js CHANGED
@@ -2,6 +2,7 @@ const path = require("node:path");
2
2
  const { exec } = require("node:child_process");
3
3
  const express = require("express");
4
4
  const profileManager = require("../main/profile-manager");
5
+ const { createProxyServer } = require("./proxy-server");
5
6
 
6
7
  function wrapAsync(handler) {
7
8
  return async (req, res) => {
@@ -77,6 +78,13 @@ function createApp() {
77
78
  }),
78
79
  );
79
80
 
81
+ app.post(
82
+ "/api/proxy/enable",
83
+ wrapAsync(async () => {
84
+ return profileManager.enableProxyMode();
85
+ }),
86
+ );
87
+
80
88
  app.post(
81
89
  "/api/open-path",
82
90
  wrapAsync(async (req) => {
@@ -110,16 +118,49 @@ function createApp() {
110
118
  function startServer(options = {}) {
111
119
  const port = Number(options.port || process.env.PORT || 3186);
112
120
  const app = createApp();
113
- const server = app.listen(port, () => {
121
+ const proxyServer = createProxyServer();
122
+ const proxyPort = profileManager.proxyPort;
123
+ const webServer = app.listen(port, () => {
114
124
  const url = `http://localhost:${port}`;
115
125
  console.log(`Codex 网页控制台已启动:${url}`);
126
+ console.log(`Codex 热更新代理已启动:${profileManager.proxyBaseUrl}`);
116
127
 
117
128
  if (options.autoOpen !== false && process.env.AUTO_OPEN_BROWSER !== "false") {
118
129
  exec(`cmd /c start "" "${url}"`);
119
130
  }
120
131
  });
121
132
 
122
- return server;
133
+ proxyServer.listen(proxyPort);
134
+
135
+ return {
136
+ webServer,
137
+ proxyServer,
138
+ close(callback) {
139
+ let pending = 2;
140
+ let closed = false;
141
+
142
+ function done(error) {
143
+ if (closed) {
144
+ return;
145
+ }
146
+
147
+ if (error) {
148
+ closed = true;
149
+ callback(error);
150
+ return;
151
+ }
152
+
153
+ pending -= 1;
154
+ if (pending === 0) {
155
+ closed = true;
156
+ callback();
157
+ }
158
+ }
159
+
160
+ webServer.close((error) => done(error));
161
+ proxyServer.close((error) => done(error));
162
+ },
163
+ };
123
164
  }
124
165
 
125
166
  if (require.main === module) {