@nvae/llmswitch 0.2.0 → 0.5.0

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.
@@ -1,19 +1,37 @@
1
- import { execFileSync, spawn } from "node:child_process";
2
- import { existsSync, readFileSync } from "node:fs";
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync } from "node:fs";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { normalizeBaseUrlForFormat } from "../utils/base-url.js";
5
- import { bridgeBaseUrl, bridgeRootUrl, getBridgePidPath, readBridgeState, readBridgeUpstreams, writeBridgeState, writeBridgeUpstream, } from "./state.js";
6
- import { DEFAULT_BRIDGE_HOST, DEFAULT_BRIDGE_PORT, emptyUpstreams, hasAnyUpstream, } from "./types.js";
6
+ import { bridgeBaseUrl, bridgeRootUrl, generateBridgeToken, readBridgeState, updateBridgeState, writeBridgeUpstream, } from "./state.js";
7
+ import { DEFAULT_BRIDGE_HOST, DEFAULT_BRIDGE_PORT, hasAnyUpstream, } from "./types.js";
8
+ import { assertBridgeListenerAllowed, formatHostForUrl, parseBridgePort, resolveBridgeListener, } from "./runtime.js";
7
9
  import { listenBridge } from "./server.js";
10
+ export class PortOccupiedError extends Error {
11
+ constructor(host, port) {
12
+ super(`端口 ${host}:${port} 已被其他进程占用;为避免误杀,llm-switch 不会自动终止该进程。`);
13
+ this.name = "PortOccupiedError";
14
+ }
15
+ }
16
+ export class LegacyBridgeRunningError extends Error {
17
+ constructor(host, port) {
18
+ super(`检测到旧版 bridge 正在 ${host}:${port} 运行。请先执行 llms bridge stop --legacy 并确认风险,然后重试。`);
19
+ this.name = "LegacyBridgeRunningError";
20
+ }
21
+ }
22
+ export class BridgeControlError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = "BridgeControlError";
26
+ }
27
+ }
8
28
  export function profileNeedsBridge(profile) {
9
29
  return profile.apiFormat === "openai-chat";
10
30
  }
11
31
  export function bridgeToolForCliTool(tool) {
12
- if (tool === "codex" || tool === "claude")
13
- return tool;
14
- return null;
32
+ return tool === "codex" || tool === "claude" ? tool : null;
15
33
  }
16
- export function upstreamFromProfile(profile, tool) {
34
+ export function upstreamFromProfile(profile, tool, clientToken = null) {
17
35
  const mode = tool === "claude" ? "chat" : profile.bridgeMode || "chat";
18
36
  return {
19
37
  baseUrl: normalizeBaseUrlForFormat(profile.apiFormat, profile.baseUrl),
@@ -23,42 +41,68 @@ export function upstreamFromProfile(profile, tool) {
23
41
  headers: profile.headers,
24
42
  profileName: profile.name,
25
43
  updatedAt: new Date().toISOString(),
44
+ clientToken,
45
+ migrationRequired: !clientToken,
26
46
  };
27
47
  }
28
- /**
29
- * Probe local bridge. Old builds may return 503 without `upstreams` — treat as
30
- * reachable but not healthy so callers can restart.
31
- */
32
- export async function probeBridge(host = readBridgeState().host, port = readBridgeState().port) {
48
+ function controlUrl(host, port, path) {
49
+ return `http://${formatHostForUrl(host)}:${port}${path}`;
50
+ }
51
+ export async function probeBridge(host = readBridgeState().listener.advertiseHost, port = readBridgeState().listener.port) {
52
+ const state = readBridgeState();
53
+ const expected = state.instance;
33
54
  try {
34
- const res = await fetch(`http://${host}:${port}/health`, {
55
+ const headers = {};
56
+ if (expected?.controlToken) {
57
+ headers["x-llm-switch-control"] = expected.controlToken;
58
+ }
59
+ const response = await fetch(controlUrl(host, port, "/health"), {
60
+ headers,
35
61
  signal: AbortSignal.timeout(800),
36
62
  });
37
63
  let body = null;
38
64
  try {
39
- body = (await res.json());
65
+ body = (await response.json());
40
66
  }
41
67
  catch {
42
68
  body = null;
43
69
  }
44
- const healthy = res.ok && body?.ok === true && body != null && "upstreams" in body;
45
- return { reachable: true, healthy };
70
+ const instanceId = typeof body?.instanceId === "string" ? body.instanceId : undefined;
71
+ const authenticated = Boolean(response.ok &&
72
+ expected &&
73
+ instanceId &&
74
+ instanceId === expected.id);
75
+ const legacy = Boolean(response.ok &&
76
+ body?.ok === true &&
77
+ body != null &&
78
+ "upstreams" in body &&
79
+ !("service" in body));
80
+ return {
81
+ reachable: true,
82
+ healthy: authenticated,
83
+ authenticated,
84
+ legacy,
85
+ instanceId,
86
+ };
46
87
  }
47
88
  catch {
48
- return { reachable: false, healthy: false };
89
+ return {
90
+ reachable: false,
91
+ healthy: false,
92
+ authenticated: false,
93
+ legacy: false,
94
+ };
49
95
  }
50
96
  }
51
- export async function isBridgeAlive(host = readBridgeState().host, port = readBridgeState().port) {
97
+ export async function isBridgeAlive(host = readBridgeState().listener.advertiseHost, port = readBridgeState().listener.port) {
52
98
  return (await probeBridge(host, port)).healthy;
53
99
  }
54
100
  export function readPid() {
55
- const path = getBridgePidPath();
56
- if (!existsSync(path))
57
- return null;
58
- const n = Number(readFileSync(path, "utf8").trim());
59
- return Number.isFinite(n) ? n : null;
101
+ return readBridgeState().instance?.pid ?? null;
60
102
  }
61
103
  export function isPidRunning(pid) {
104
+ if (!Number.isInteger(pid) || pid < 1 || pid > 2_147_483_647)
105
+ return false;
62
106
  try {
63
107
  process.kill(pid, 0);
64
108
  return true;
@@ -67,65 +111,86 @@ export function isPidRunning(pid) {
67
111
  return false;
68
112
  }
69
113
  }
70
- function urlForTool(tool, host, port, pid) {
71
- const upstreams = readBridgeUpstreams();
72
- const state = { host, port, upstreams, pid };
73
- return tool === "claude" ? bridgeRootUrl(state) : bridgeBaseUrl(state);
114
+ function connectionForTool(tool, state, clientToken) {
115
+ return {
116
+ baseUrl: tool === "claude" ? bridgeRootUrl(state) : bridgeBaseUrl(state),
117
+ clientToken,
118
+ };
119
+ }
120
+ function desiredListener() {
121
+ const current = readBridgeState();
122
+ const host = process.env.LLM_SWITCH_BRIDGE_HOST || current.listener.bindHost;
123
+ const port = process.env.LLM_SWITCH_BRIDGE_PORT
124
+ ? parseBridgePort(process.env.LLM_SWITCH_BRIDGE_PORT)
125
+ : current.listener.port;
126
+ const allowRemote = current.listener.allowRemote;
127
+ return resolveBridgeListener({ host, port, allowRemote });
74
128
  }
75
129
  /**
76
- * Configure per-tool upstream and ensure local bridge is listening.
77
- * Returns Codex base (`…/v1`) or Claude root (`…` without /v1).
130
+ * Preflight the listen address before writing an upstream, then configure the
131
+ * side and ensure an authenticated v2 daemon is running.
78
132
  */
79
133
  export async function ensureBridgeForProfile(profile, tool) {
80
- const upstream = upstreamFromProfile(profile, tool);
81
- writeBridgeUpstream(tool, upstream);
82
- const state = readBridgeState();
83
- const host = state.host || DEFAULT_BRIDGE_HOST;
84
- const port = Number(process.env.LLM_SWITCH_BRIDGE_PORT) ||
85
- state.port ||
86
- DEFAULT_BRIDGE_PORT;
87
- const upstreams = readBridgeUpstreams();
88
- writeBridgeState({
89
- ...state,
90
- host,
91
- port,
92
- upstreams,
93
- pid: state.pid,
94
- });
95
- const probe = await probeBridge(host, port);
96
- if (probe.healthy) {
97
- return urlForTool(tool, host, port, state.pid);
98
- }
99
- // Stale / incompatible process holding the port (e.g. pre-dual-upstream build).
100
- if (probe.reachable) {
101
- await forceStopBridge(host, port);
134
+ const listener = desiredListener();
135
+ const before = await probeBridge(listener.advertiseHost, listener.port);
136
+ if (before.reachable && !before.healthy) {
137
+ if (before.legacy) {
138
+ throw new LegacyBridgeRunningError(listener.bindHost, listener.port);
139
+ }
140
+ throw new PortOccupiedError(listener.bindHost, listener.port);
102
141
  }
103
- await startBridgeDaemon(host, port);
104
- for (let i = 0; i < 50; i++) {
105
- if (await isBridgeAlive(host, port)) {
106
- return urlForTool(tool, host, port, readPid());
142
+ const clientToken = generateBridgeToken();
143
+ const upstream = upstreamFromProfile(profile, tool, clientToken);
144
+ const configured = updateBridgeState((state) => ({
145
+ ...state,
146
+ listener,
147
+ host: listener.advertiseHost,
148
+ port: listener.port,
149
+ upstreams: { ...state.upstreams, [tool]: upstream },
150
+ }));
151
+ if (!before.healthy) {
152
+ await startBridgeDaemon(listener.bindHost, listener.port, listener.allowRemote);
153
+ for (let attempt = 0; attempt < 50; attempt += 1) {
154
+ if (await isBridgeAlive(listener.advertiseHost, listener.port))
155
+ break;
156
+ await new Promise((resolve) => setTimeout(resolve, 100));
157
+ }
158
+ if (!(await isBridgeAlive(listener.advertiseHost, listener.port))) {
159
+ throw new BridgeControlError(`Bridge 启动超时(${listener.bindHost}:${listener.port})。可手动运行:llms bridge serve`);
107
160
  }
108
- await new Promise((r) => setTimeout(r, 100));
109
161
  }
110
- throw new Error(`Bridge 启动超时(${host}:${port})。可手动运行:llms bridge serve`);
162
+ return connectionForTool(tool, readBridgeState() || configured, clientToken);
111
163
  }
112
164
  export async function clearBridgeUpstream(tool) {
113
165
  writeBridgeUpstream(tool, null);
114
- const upstreams = readBridgeUpstreams();
115
- if (!hasAnyUpstream(upstreams)) {
166
+ const state = readBridgeState();
167
+ if (!hasAnyUpstream(state.upstreams) && state.instance) {
116
168
  await stopBridge();
117
169
  }
118
170
  }
119
- export async function startBridgeDaemon(host = DEFAULT_BRIDGE_HOST, port = DEFAULT_BRIDGE_PORT) {
120
- if (await isBridgeAlive(host, port)) {
171
+ export async function startBridgeDaemon(host = DEFAULT_BRIDGE_HOST, port = DEFAULT_BRIDGE_PORT, allowRemote = false) {
172
+ const listener = resolveBridgeListener({ host, port, allowRemote });
173
+ const probe = await probeBridge(listener.advertiseHost, listener.port);
174
+ if (probe.healthy)
121
175
  return readPid() || 0;
122
- }
123
- const probe = await probeBridge(host, port);
124
176
  if (probe.reachable) {
125
- await forceStopBridge(host, port);
177
+ if (probe.legacy)
178
+ throw new LegacyBridgeRunningError(host, port);
179
+ throw new PortOccupiedError(host, port);
126
180
  }
181
+ updateBridgeState((state) => ({
182
+ ...state,
183
+ listener,
184
+ host: listener.advertiseHost,
185
+ port: listener.port,
186
+ instance: null,
187
+ pid: null,
188
+ }));
127
189
  const entry = resolveCliEntry();
128
- const child = spawn(process.execPath, [entry, "bridge", "serve", "--host", host, "--port", String(port)], {
190
+ const args = [entry, "bridge", "serve", "--host", host, "--port", String(port)];
191
+ if (allowRemote)
192
+ args.push("--allow-remote");
193
+ const child = spawn(process.execPath, args, {
129
194
  detached: true,
130
195
  stdio: "ignore",
131
196
  env: {
@@ -135,106 +200,136 @@ export async function startBridgeDaemon(host = DEFAULT_BRIDGE_HOST, port = DEFAU
135
200
  },
136
201
  });
137
202
  child.unref();
138
- const pid = child.pid;
139
- if (!pid)
140
- throw new Error("无法启动 bridge 进程");
141
- const state = readBridgeState();
142
- writeBridgeState({
143
- ...state,
144
- host,
145
- port,
146
- pid,
147
- upstreams: state.upstreams,
148
- });
149
- return pid;
150
- }
151
- /** Stop by recorded pid, then free the listen port if still held. */
152
- export async function forceStopBridge(host = readBridgeState().host, port = readBridgeState().port) {
153
- await stopBridge();
154
- await killListenersOnPort(port);
155
- // Brief wait so TIME_WAIT / bind release settles.
156
- for (let i = 0; i < 20; i++) {
157
- const probe = await probeBridge(host, port);
158
- if (!probe.reachable)
159
- return;
160
- await new Promise((r) => setTimeout(r, 50));
161
- }
203
+ if (!child.pid)
204
+ throw new BridgeControlError("无法启动 bridge 进程");
205
+ return child.pid;
162
206
  }
163
207
  export async function stopBridge() {
164
208
  const state = readBridgeState();
165
- const pid = state.pid || readPid();
166
- let stopped = false;
167
- if (pid && isPidRunning(pid)) {
168
- try {
169
- process.kill(pid, "SIGTERM");
170
- stopped = true;
171
- }
172
- catch {
173
- // ignore
174
- }
209
+ const instance = state.instance;
210
+ if (!instance)
211
+ return false;
212
+ const probe = await probeBridge(state.listener.advertiseHost, state.listener.port);
213
+ if (!probe.authenticated || probe.instanceId !== instance.id) {
214
+ throw new BridgeControlError("无法验证 bridge 实例身份;为避免误杀,未发送任何进程信号。");
175
215
  }
176
- writeBridgeState({
177
- ...state,
178
- pid: null,
179
- upstreams: state.upstreams,
180
- });
181
- return stopped;
182
- }
183
- function killListenersOnPort(port) {
184
- if (process.platform === "win32")
185
- return;
216
+ let response;
186
217
  try {
187
- const out = execFileSync("lsof", ["-ti", `tcp:${port}`, `-sTCP:LISTEN`], { encoding: "utf8" });
188
- for (const line of out.split(/\n/)) {
189
- const pid = Number(line.trim());
190
- if (!pid || pid === process.pid)
191
- continue;
192
- try {
193
- process.kill(pid, "SIGTERM");
194
- }
195
- catch {
196
- // ignore
197
- }
198
- }
218
+ response = await fetch(controlUrl(state.listener.advertiseHost, state.listener.port, "/_control/shutdown"), {
219
+ method: "POST",
220
+ headers: {
221
+ "content-type": "application/json",
222
+ "x-llm-switch-control": instance.controlToken,
223
+ },
224
+ body: JSON.stringify({ instanceId: instance.id }),
225
+ signal: AbortSignal.timeout(2_000),
226
+ });
199
227
  }
200
- catch {
201
- // lsof miss / no listeners ignore
228
+ catch (error) {
229
+ throw new BridgeControlError(`Bridge 协作关闭失败:${error instanceof Error ? error.message : String(error)}`);
230
+ }
231
+ if (!response.ok) {
232
+ throw new BridgeControlError(`Bridge 拒绝关闭请求(HTTP ${response.status})`);
233
+ }
234
+ for (let attempt = 0; attempt < 100; attempt += 1) {
235
+ if (readBridgeState().instance?.id !== instance.id)
236
+ return true;
237
+ await new Promise((resolve) => setTimeout(resolve, 20));
202
238
  }
239
+ throw new BridgeControlError("Bridge 已接受关闭请求,但未在宽限期内清除实例身份");
203
240
  }
204
- export async function runBridgeForeground(host, port) {
205
- const state = readBridgeState();
206
- writeBridgeState({
241
+ /** Compatibility alias: v2 never force-kills a listener. */
242
+ export async function forceStopBridge() {
243
+ await stopBridge();
244
+ }
245
+ export async function runBridgeForeground(host, port, allowRemote = false) {
246
+ assertBridgeListenerAllowed(host, allowRemote);
247
+ const listener = resolveBridgeListener({ host, port, allowRemote });
248
+ const previous = readBridgeState();
249
+ const instance = {
250
+ id: randomUUID(),
251
+ controlToken: generateBridgeToken(),
252
+ pid: process.pid,
253
+ startedAt: new Date().toISOString(),
254
+ };
255
+ updateBridgeState((state) => ({
207
256
  ...state,
208
- host,
209
- port,
257
+ listener,
258
+ host: listener.advertiseHost,
259
+ port: listener.port,
210
260
  pid: process.pid,
211
- upstreams: state.upstreams.codex || state.upstreams.claude
212
- ? state.upstreams
213
- : readBridgeUpstreams() || emptyUpstreams(),
261
+ instance,
262
+ }));
263
+ let resolveClosed;
264
+ const closed = new Promise((resolve) => {
265
+ resolveClosed = resolve;
214
266
  });
215
- const server = await listenBridge(port, host);
216
- const shutdown = () => {
217
- server.close(() => process.exit(0));
267
+ let server = null;
268
+ let closing = false;
269
+ const clearIdentity = () => {
270
+ updateBridgeState((state) => state.instance?.id === instance.id
271
+ ? { ...state, instance: null, pid: null }
272
+ : state);
218
273
  };
219
- process.on("SIGINT", shutdown);
220
- process.on("SIGTERM", shutdown);
221
- console.error(`llm-switch bridge listening on http://${host}:${port} (POST /v1/responses · POST /v1/messages → upstream chat/completions)`);
222
- await new Promise(() => undefined);
274
+ const shutdown = async (requestedId = instance.id) => {
275
+ if (requestedId !== instance.id || closing)
276
+ return;
277
+ closing = true;
278
+ if (server) {
279
+ const forceTimer = setTimeout(() => server?.closeAllConnections(), 30_000);
280
+ await new Promise((resolve) => server.close(() => resolve()));
281
+ clearTimeout(forceTimer);
282
+ }
283
+ clearIdentity();
284
+ resolveClosed?.();
285
+ };
286
+ try {
287
+ server = await listenBridge(listener.port, listener.bindHost, {
288
+ controlToken: instance.controlToken,
289
+ instanceId: instance.id,
290
+ onShutdown: shutdown,
291
+ });
292
+ }
293
+ catch (error) {
294
+ try {
295
+ clearIdentity();
296
+ }
297
+ catch {
298
+ // Preserve the original bind error.
299
+ }
300
+ if (error.code === "EADDRINUSE") {
301
+ throw new PortOccupiedError(listener.bindHost, listener.port);
302
+ }
303
+ // Restore the previous listener only when no newer instance replaced us.
304
+ updateBridgeState((state) => state.instance
305
+ ? state
306
+ : {
307
+ ...state,
308
+ listener: previous.listener,
309
+ host: previous.listener.advertiseHost,
310
+ port: previous.listener.port,
311
+ });
312
+ throw error;
313
+ }
314
+ const onSignal = () => {
315
+ void shutdown();
316
+ };
317
+ process.once("SIGINT", onSignal);
318
+ process.once("SIGTERM", onSignal);
319
+ console.error(`llm-switch bridge listening on http://${formatHostForUrl(listener.bindHost)}:${listener.port} (authenticated)`);
320
+ await closed;
321
+ process.removeListener("SIGINT", onSignal);
322
+ process.removeListener("SIGTERM", onSignal);
223
323
  }
224
- /**
225
- * Prefer the entry currently running this CLI so `bun run ./src/index.ts`
226
- * respawns the same source tree; published installs use dist/index.js.
227
- */
228
324
  function resolveCliEntry() {
229
325
  const running = process.argv[1];
230
- if (running && existsSync(running)) {
326
+ if (running && existsSync(running))
231
327
  return running;
232
- }
233
328
  const compiled = fileURLToPath(new URL("../index.js", import.meta.url));
234
329
  if (existsSync(compiled))
235
330
  return compiled;
236
331
  const source = fileURLToPath(new URL("../index.ts", import.meta.url));
237
332
  if (existsSync(source))
238
333
  return source;
239
- throw new Error("无法定位 llmswitch 入口文件");
334
+ throw new BridgeControlError("无法定位 llmswitch 入口文件");
240
335
  }
@@ -0,0 +1,199 @@
1
+ import { isIP } from "node:net";
2
+ export const DEFAULT_BRIDGE_RUNTIME_LIMITS = Object.freeze({
3
+ maxBodyBytes: 16_777_216,
4
+ maxResponseBytes: 33_554_432,
5
+ maxSseFrameBytes: 2_097_152,
6
+ connectTimeoutMs: 30_000,
7
+ idleTimeoutMs: 90_000,
8
+ totalTimeoutMs: 600_000,
9
+ maxConcurrency: 16,
10
+ rateLimitPerMinute: 120,
11
+ });
12
+ const RUNTIME_LIMIT_SPECS = [
13
+ {
14
+ env: "LLM_SWITCH_MAX_BODY_BYTES",
15
+ key: "maxBodyBytes",
16
+ min: 1_024,
17
+ max: 67_108_864,
18
+ },
19
+ {
20
+ env: "LLM_SWITCH_MAX_RESPONSE_BYTES",
21
+ key: "maxResponseBytes",
22
+ min: 1_024,
23
+ max: 134_217_728,
24
+ },
25
+ {
26
+ env: "LLM_SWITCH_MAX_SSE_FRAME_BYTES",
27
+ key: "maxSseFrameBytes",
28
+ min: 1_024,
29
+ max: 16_777_216,
30
+ },
31
+ {
32
+ env: "LLM_SWITCH_CONNECT_TIMEOUT_MS",
33
+ key: "connectTimeoutMs",
34
+ min: 1_000,
35
+ max: 120_000,
36
+ },
37
+ {
38
+ env: "LLM_SWITCH_IDLE_TIMEOUT_MS",
39
+ key: "idleTimeoutMs",
40
+ min: 1_000,
41
+ max: 600_000,
42
+ },
43
+ {
44
+ env: "LLM_SWITCH_TOTAL_TIMEOUT_MS",
45
+ key: "totalTimeoutMs",
46
+ min: 1_000,
47
+ max: 3_600_000,
48
+ },
49
+ {
50
+ env: "LLM_SWITCH_MAX_CONCURRENCY",
51
+ key: "maxConcurrency",
52
+ min: 1,
53
+ max: 128,
54
+ },
55
+ {
56
+ env: "LLM_SWITCH_RATE_LIMIT_PER_MINUTE",
57
+ key: "rateLimitPerMinute",
58
+ min: 1,
59
+ max: 10_000,
60
+ },
61
+ ];
62
+ export function parseBridgeRuntimeLimits(env = process.env) {
63
+ const parsed = { ...DEFAULT_BRIDGE_RUNTIME_LIMITS };
64
+ for (const spec of RUNTIME_LIMIT_SPECS) {
65
+ const raw = env[spec.env];
66
+ if (raw === undefined)
67
+ continue;
68
+ if (!/^\d+$/.test(raw)) {
69
+ throw new Error(`${spec.env} 必须是 ${spec.min}..${spec.max} 范围内的整数`);
70
+ }
71
+ const value = Number(raw);
72
+ if (!Number.isSafeInteger(value) || value < spec.min || value > spec.max) {
73
+ throw new Error(`${spec.env} 必须是 ${spec.min}..${spec.max} 范围内的整数`);
74
+ }
75
+ parsed[spec.key] = value;
76
+ }
77
+ return parsed;
78
+ }
79
+ export function parseBridgePort(value) {
80
+ const raw = typeof value === "number" ? String(value) : value;
81
+ if (!/^\d+$/.test(raw)) {
82
+ throw new Error("Port 必须是 1..65535 范围内的整数");
83
+ }
84
+ const port = Number(raw);
85
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
86
+ throw new Error("Port 必须是 1..65535 范围内的整数");
87
+ }
88
+ return port;
89
+ }
90
+ export function normalizeHost(host) {
91
+ const trimmed = host.trim();
92
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
93
+ return trimmed.slice(1, -1);
94
+ }
95
+ return trimmed;
96
+ }
97
+ function parseIpv4(value) {
98
+ const parts = value.split(".");
99
+ if (parts.length !== 4)
100
+ return null;
101
+ const bytes = [];
102
+ for (const part of parts) {
103
+ if (!/^\d{1,3}$/.test(part))
104
+ return null;
105
+ const byte = Number(part);
106
+ if (byte < 0 || byte > 255)
107
+ return null;
108
+ bytes.push(byte);
109
+ }
110
+ return bytes;
111
+ }
112
+ function parseIpv6Groups(value) {
113
+ let host = value.toLowerCase();
114
+ if (host.includes("%"))
115
+ return null;
116
+ const lastColon = host.lastIndexOf(":");
117
+ const tail = lastColon >= 0 ? host.slice(lastColon + 1) : host;
118
+ if (tail.includes(".")) {
119
+ const ipv4 = parseIpv4(tail);
120
+ if (!ipv4)
121
+ return null;
122
+ const high = ((ipv4[0] ?? 0) << 8) | (ipv4[1] ?? 0);
123
+ const low = ((ipv4[2] ?? 0) << 8) | (ipv4[3] ?? 0);
124
+ host = `${host.slice(0, lastColon + 1)}${high.toString(16)}:${low.toString(16)}`;
125
+ }
126
+ const halves = host.split("::");
127
+ if (halves.length > 2)
128
+ return null;
129
+ const parseHalf = (half) => {
130
+ if (!half)
131
+ return [];
132
+ const groups = [];
133
+ for (const part of half.split(":")) {
134
+ if (!/^[0-9a-f]{1,4}$/.test(part))
135
+ return null;
136
+ groups.push(Number.parseInt(part, 16));
137
+ }
138
+ return groups;
139
+ };
140
+ const left = parseHalf(halves[0] ?? "");
141
+ const right = parseHalf(halves[1] ?? "");
142
+ if (!left || !right)
143
+ return null;
144
+ if (halves.length === 1) {
145
+ return left.length === 8 ? left : null;
146
+ }
147
+ const missing = 8 - left.length - right.length;
148
+ if (missing < 1)
149
+ return null;
150
+ return [...left, ...Array.from({ length: missing }, () => 0), ...right];
151
+ }
152
+ export function isLoopbackHost(host) {
153
+ const normalized = normalizeHost(host).toLowerCase();
154
+ if (normalized === "localhost")
155
+ return true;
156
+ const ipv4 = parseIpv4(normalized);
157
+ if (ipv4)
158
+ return ipv4[0] === 127;
159
+ const groups = parseIpv6Groups(normalized);
160
+ if (!groups)
161
+ return false;
162
+ const isV6Loopback = groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1;
163
+ if (isV6Loopback)
164
+ return true;
165
+ const isMappedIpv4 = groups.slice(0, 5).every((group) => group === 0) &&
166
+ groups[5] === 0xffff;
167
+ return isMappedIpv4 && ((groups[6] ?? 0) >> 8) === 127;
168
+ }
169
+ export function formatHostForUrl(host) {
170
+ const normalized = normalizeHost(host);
171
+ return isIP(normalized) === 6 || normalized.includes(":")
172
+ ? `[${normalized}]`
173
+ : normalized;
174
+ }
175
+ export function assertBridgeListenerAllowed(host, allowRemote) {
176
+ if (!isLoopbackHost(host) && !allowRemote) {
177
+ throw new Error(`非回环 bridge 监听地址 ${host || "(empty)"} 必须显式传入 --allow-remote`);
178
+ }
179
+ }
180
+ export function advertiseHostForBind(host) {
181
+ const normalized = normalizeHost(host);
182
+ if (!normalized || normalized === "0.0.0.0")
183
+ return "127.0.0.1";
184
+ if (normalized === "::")
185
+ return "::1";
186
+ return normalized;
187
+ }
188
+ export function resolveBridgeListener(options) {
189
+ const bindHost = normalizeHost(options.host);
190
+ assertBridgeListenerAllowed(bindHost, options.allowRemote);
191
+ return {
192
+ bindHost,
193
+ advertiseHost: options.advertiseHost
194
+ ? normalizeHost(options.advertiseHost)
195
+ : advertiseHostForBind(bindHost),
196
+ port: parseBridgePort(options.port),
197
+ allowRemote: options.allowRemote,
198
+ };
199
+ }