dsh-provider-proxy 0.3.0 → 0.4.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.
package/lib/index.js CHANGED
@@ -4,10 +4,24 @@ var __export = (target, all) => {
4
4
  __defProp(target, name2, { get: all[name2], enumerable: true });
5
5
  };
6
6
 
7
+ // src/index.ts
8
+ import { fetch as undiciFetch2 } from "undici";
9
+
7
10
  // src/fetch-router.ts
8
11
  import { AsyncLocalStorage } from "node:async_hooks";
9
12
  import { fetch as undiciFetch } from "undici";
10
13
  var STATE_KEY = /* @__PURE__ */ Symbol.for("dsh-provider-proxy.fetch-router.v1");
14
+ function requestHost(input) {
15
+ try {
16
+ if (typeof input === "string") return new URL(input).hostname;
17
+ if (input instanceof URL) return input.hostname;
18
+ const url = input?.url;
19
+ if (typeof url === "string") return new URL(url).hostname;
20
+ } catch {
21
+ return void 0;
22
+ }
23
+ return void 0;
24
+ }
11
25
  function installSharedRouter() {
12
26
  const current = globalThis[STATE_KEY];
13
27
  if (current !== void 0) {
@@ -26,6 +40,10 @@ function installSharedRouter() {
26
40
  if (init !== void 0 && init !== null && init.dispatcher !== void 0) {
27
41
  return originalFetch.call(globalThis, input, init);
28
42
  }
43
+ if (active.decide !== void 0 && active.decide(requestHost(input)) === "direct") {
44
+ return originalFetch.call(globalThis, input, init);
45
+ }
46
+ active.onHit?.();
29
47
  return undiciFetch(input, {
30
48
  ...init,
31
49
  dispatcher: active.dispatcher
@@ -81,11 +99,11 @@ function acquireFetchRouter() {
81
99
  const state = installSharedRouter();
82
100
  let released = false;
83
101
  return {
84
- run(dispatcher, callback) {
85
- return state.storage.run({ dispatcher }, callback);
102
+ run(store, callback) {
103
+ return state.storage.run(store, callback);
86
104
  },
87
- wrap(dispatcher, factory) {
88
- return contextualIterable(state.storage, { dispatcher }, factory);
105
+ wrap(store, factory) {
106
+ return contextualIterable(state.storage, store, factory);
89
107
  },
90
108
  release() {
91
109
  if (released) return;
@@ -1011,16 +1029,31 @@ var ProviderProxyRoute = z.object({
1011
1029
  /** Optional proxy username. */
1012
1030
  username: z.string().default(""),
1013
1031
  /** SOCKS5 proxy password. Only used when `protocol` is `socks5`. */
1014
- password: z.string().default("")
1032
+ password: z.string().default(""),
1033
+ /**
1034
+ * Comma/space-separated TARGET hosts that must go through the proxy. Empty
1035
+ * means "no restriction" (every target is proxied). Suffix match.
1036
+ */
1037
+ onlyHosts: z.string().default(""),
1038
+ /** Comma/space-separated TARGET hosts that bypass the proxy; takes priority over `onlyHosts`. */
1039
+ bypassHosts: z.string().default("")
1015
1040
  });
1016
1041
  var Config = z.object({
1017
1042
  /** Per-provider proxy routes, keyed by provider route id (e.g. `sensenova`). */
1018
- providers: vol(z.dict(ProviderProxyRoute).default({}))
1043
+ providers: vol(z.dict(ProviderProxyRoute).default({})),
1044
+ /**
1045
+ * URL the per-provider connection self-check requests THROUGH the proxy.
1046
+ * Volatile, so a settings write does not remount the plugin; the default is an
1047
+ * IP echo service, which lets the check report the exit address.
1048
+ */
1049
+ testUrl: vol(z.string().default("https://api.ipify.org?format=json"))
1019
1050
  });
1020
1051
  function normalizeRoute(provider, raw) {
1021
1052
  const route = ProviderProxyRoute(raw ?? {});
1022
1053
  const value = typeof route.get === "function" ? route.get() : route;
1023
- if (!value.enabled) return { ...value, port: value.port || 0 };
1054
+ const onlyHosts = value.onlyHosts.trim().toLowerCase();
1055
+ const bypassHosts = value.bypassHosts.trim().toLowerCase();
1056
+ if (!value.enabled) return { ...value, port: value.port || 0, onlyHosts, bypassHosts };
1024
1057
  const host = value.host.trim();
1025
1058
  if (host.length === 0) {
1026
1059
  throw new TypeError(`dsh-provider-proxy: provider "${provider}" \u542F\u7528\u4E86\u4EE3\u7406\uFF0C\u4F46\u672A\u586B\u5199\u4EE3\u7406\u5730\u5740`);
@@ -1033,14 +1066,14 @@ function normalizeRoute(provider, raw) {
1033
1066
  `dsh-provider-proxy: provider "${provider}" \u7684\u4EE3\u7406\u7AEF\u53E3\u65E0\u6548\uFF08\u9700\u4E3A 1-65535 \u7684\u6574\u6570\uFF09`
1034
1067
  );
1035
1068
  }
1036
- return { ...value, host, username: value.username.trim() };
1069
+ return { ...value, host, username: value.username.trim(), onlyHosts, bypassHosts };
1037
1070
  }
1038
1071
 
1039
1072
  // src/proxy-table.ts
1040
1073
  function proxyUrl(route) {
1041
- const auth = route.username.length === 0 ? "" : `${encodeURIComponent(route.username).replace(/%3A/gi, ":")}@`;
1074
+ const credentials = route.username.length === 0 ? "" : route.password.length > 0 && route.protocol !== "socks5" ? `${encodeURIComponent(route.username)}:${encodeURIComponent(route.password)}@` : `${encodeURIComponent(route.username)}@`;
1042
1075
  const host = route.host.includes(":") && !route.host.startsWith("[") ? `[${route.host}]` : route.host;
1043
- return `${route.protocol}://${auth}${host}:${route.port}`;
1076
+ return `${route.protocol}://${credentials}${host}:${route.port}`;
1044
1077
  }
1045
1078
  function displayOrigin(route, url) {
1046
1079
  if (route.protocol !== "socks5") return new URL(url).origin;
@@ -1056,6 +1089,26 @@ function createDispatcher(route, url, password) {
1056
1089
  }
1057
1090
  return new ProxyAgent({ uri: url });
1058
1091
  }
1092
+ function parseHostList(raw) {
1093
+ return raw.split(/[,\s]+/).map((entry) => entry.trim().replace(/^\*?\./, "")).filter((entry) => entry.length > 0);
1094
+ }
1095
+ function hostMatches(host, rules) {
1096
+ for (const rule of rules) {
1097
+ if (rule === "*") return true;
1098
+ if (host === rule || host.endsWith(`.${rule}`)) return true;
1099
+ }
1100
+ return false;
1101
+ }
1102
+ function buildDecide(route) {
1103
+ const only = parseHostList(route.onlyHosts);
1104
+ const bypass = parseHostList(route.bypassHosts);
1105
+ return (host) => {
1106
+ if (host === void 0) return "proxy";
1107
+ if (hostMatches(host, bypass)) return "direct";
1108
+ if (only.length > 0 && !hostMatches(host, only)) return "direct";
1109
+ return "proxy";
1110
+ };
1111
+ }
1059
1112
  function createProxyTable(config) {
1060
1113
  const entries = /* @__PURE__ */ new Map();
1061
1114
  const problems = /* @__PURE__ */ new Map();
@@ -1075,6 +1128,13 @@ function createProxyTable(config) {
1075
1128
  );
1076
1129
  continue;
1077
1130
  }
1131
+ if (route.protocol !== "socks5" && route.password.length > 0 && route.username.length === 0) {
1132
+ problems.set(
1133
+ provider,
1134
+ "dsh-provider-proxy: \u914D\u7F6E\u4E86\u4EE3\u7406\u5BC6\u7801\u4F46\u6CA1\u6709\u7528\u6237\u540D\uFF1B\u8BF7\u8865\u5168\u7528\u6237\u540D\u6216\u6E05\u7A7A\u5BC6\u7801"
1135
+ );
1136
+ continue;
1137
+ }
1078
1138
  const url = proxyUrl(route);
1079
1139
  let dispatcher;
1080
1140
  try {
@@ -1083,7 +1143,12 @@ function createProxyTable(config) {
1083
1143
  problems.set(provider, error instanceof Error ? error.message : String(error));
1084
1144
  continue;
1085
1145
  }
1086
- entries.set(provider, { provider, proxyOrigin: displayOrigin(route, url), dispatcher });
1146
+ entries.set(provider, {
1147
+ provider,
1148
+ proxyOrigin: displayOrigin(route, url),
1149
+ dispatcher,
1150
+ decide: buildDecide(route)
1151
+ });
1087
1152
  }
1088
1153
  return { entries, problems };
1089
1154
  }
@@ -1096,6 +1161,7 @@ async function closeTable(entries) {
1096
1161
  // src/index.ts
1097
1162
  var name = "dsh-provider-proxy";
1098
1163
  var inject = ["llm"];
1164
+ var WEB_SEARCH_KEY = "web-search";
1099
1165
  function plainProviders(config) {
1100
1166
  const raw = config?.providers;
1101
1167
  if (raw === void 0) return {};
@@ -1108,6 +1174,86 @@ function apply(ctx, config) {
1108
1174
  let current = /* @__PURE__ */ new Map();
1109
1175
  let problems = /* @__PURE__ */ new Map();
1110
1176
  let closed = false;
1177
+ let webAvailable = false;
1178
+ const hits = /* @__PURE__ */ new Map();
1179
+ const bump = (provider) => {
1180
+ hits.set(provider, (hits.get(provider) ?? 0) + 1);
1181
+ };
1182
+ const DEFAULT_TEST_URL = "https://api.ipify.org?format=json";
1183
+ const TEST_TIMEOUT_MS = 1e4;
1184
+ const selfChecks = /* @__PURE__ */ new Map();
1185
+ const plainTestUrl = () => {
1186
+ const raw = config?.testUrl;
1187
+ const snapshot2 = raw?.get;
1188
+ const value = typeof snapshot2 === "function" ? snapshot2.call(raw) : raw;
1189
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : DEFAULT_TEST_URL;
1190
+ };
1191
+ const causesOf = (error) => {
1192
+ const out = [];
1193
+ const seen = /* @__PURE__ */ new Set();
1194
+ const queue = [error];
1195
+ while (queue.length > 0) {
1196
+ const node = queue.shift();
1197
+ if (node === null || typeof node !== "object" || seen.has(node)) continue;
1198
+ seen.add(node);
1199
+ out.push(node);
1200
+ const cause = node.cause;
1201
+ if (cause !== void 0 && cause !== null) queue.push(cause);
1202
+ const errors = node.errors;
1203
+ if (Array.isArray(errors)) queue.push(...errors);
1204
+ }
1205
+ return out;
1206
+ };
1207
+ const classifyTestError = (error) => {
1208
+ const chain = causesOf(error);
1209
+ const names = chain.map((node) => node.name).filter((name2) => typeof name2 === "string");
1210
+ const codes = chain.map((node) => node.code).filter((code2) => typeof code2 === "string");
1211
+ const message = error instanceof Error ? error.message : String(error);
1212
+ if (names.includes("TimeoutError") || names.includes("AbortError")) {
1213
+ return { kind: "timeout", error: `\u8D85\u65F6\uFF08${TEST_TIMEOUT_MS / 1e3}s\uFF09\uFF1A\u4EE3\u7406\u6216\u76EE\u6807\u65E0\u54CD\u5E94` };
1214
+ }
1215
+ const connectivity = ["ECONNREFUSED", "ENOTFOUND", "EHOSTUNREACH", "ENETUNREACH", "ECONNRESET", "EAI_AGAIN"];
1216
+ const code = connectivity.find((candidate) => codes.includes(candidate));
1217
+ if (code !== void 0) {
1218
+ return { kind: "proxy-unreachable", error: `\u4EE3\u7406\u4E0D\u53EF\u8FBE\uFF08${code}\uFF09\uFF1A${message}` };
1219
+ }
1220
+ if (names.includes("ProxyAuthentication") || message.includes("407")) {
1221
+ return { kind: "proxy-auth", error: `\u4EE3\u7406\u8981\u6C42\u8BA4\u8BC1\uFF08HTTP 407\uFF09\uFF1A${message}` };
1222
+ }
1223
+ return { kind: "target", error: `\u8BF7\u6C42\u5931\u8D25\uFF1A${message}` };
1224
+ };
1225
+ const runSelfCheck = async (provider) => {
1226
+ const route = current.get(provider);
1227
+ if (route === void 0) {
1228
+ return { ok: false, kind: "no-route", error: `provider "${provider}" \u672A\u542F\u7528\u4EE3\u7406` };
1229
+ }
1230
+ const started = performance.now();
1231
+ try {
1232
+ const response = await undiciFetch2(plainTestUrl(), {
1233
+ dispatcher: route.dispatcher,
1234
+ signal: AbortSignal.timeout(TEST_TIMEOUT_MS)
1235
+ });
1236
+ const ms = Math.round(performance.now() - started);
1237
+ if (response.status === 407) {
1238
+ return { ok: false, ms, status: 407, kind: "proxy-auth", error: "\u4EE3\u7406\u8981\u6C42\u8BA4\u8BC1\uFF08HTTP 407\uFF09" };
1239
+ }
1240
+ let exitIp;
1241
+ try {
1242
+ const body = await response.json();
1243
+ if (typeof body?.ip === "string" && body.ip.length > 0) exitIp = body.ip;
1244
+ } catch {
1245
+ }
1246
+ return {
1247
+ ok: response.ok === true,
1248
+ ms,
1249
+ status: response.status,
1250
+ ...exitIp === void 0 ? {} : { exitIp },
1251
+ kind: response.ok === true ? "ok" : "target"
1252
+ };
1253
+ } catch (error) {
1254
+ return { ok: false, ms: Math.round(performance.now() - started), ...classifyTestError(error) };
1255
+ }
1256
+ };
1111
1257
  try {
1112
1258
  ctx.logger.info("dsh-provider-proxy mounted (pid %d)", process.pid);
1113
1259
  } catch {
@@ -1147,6 +1293,9 @@ function apply(ctx, config) {
1147
1293
  }
1148
1294
  } catch {
1149
1295
  }
1296
+ if (webAvailable && out.every((row) => row.id !== WEB_SEARCH_KEY)) {
1297
+ out.push({ id: WEB_SEARCH_KEY, name: "Web \u641C\u7D22", live: true });
1298
+ }
1150
1299
  out.sort((a, b) => a.name.localeCompare(b.name));
1151
1300
  return { providers: out };
1152
1301
  };
@@ -1157,7 +1306,11 @@ function apply(ctx, config) {
1157
1306
  path: "/dsh-provider-proxy/providers",
1158
1307
  handler: (_req, res) => {
1159
1308
  try {
1160
- const text = JSON.stringify({ ...catalog(), problems: Object.fromEntries(problems) });
1309
+ const text = JSON.stringify({
1310
+ ...catalog(),
1311
+ problems: Object.fromEntries(problems),
1312
+ hits: Object.fromEntries(hits)
1313
+ });
1161
1314
  res.writeHead(200, {
1162
1315
  "content-type": "application/json; charset=utf-8",
1163
1316
  "cache-control": "no-store",
@@ -1170,10 +1323,87 @@ function apply(ctx, config) {
1170
1323
  }
1171
1324
  }
1172
1325
  });
1326
+ wctx?.webServer?.register?.({
1327
+ kind: "exact",
1328
+ path: "/dsh-provider-proxy/test",
1329
+ handler: (req, res) => {
1330
+ const send = (status, payload) => {
1331
+ const text = JSON.stringify(payload);
1332
+ res.writeHead(status, {
1333
+ "content-type": "application/json; charset=utf-8",
1334
+ "cache-control": "no-store",
1335
+ "content-length": Buffer.byteLength(text)
1336
+ });
1337
+ res.end(text);
1338
+ };
1339
+ if (req?.method !== "POST") {
1340
+ send(405, { ok: false, kind: "method", error: "\u81EA\u68C0\u7AEF\u70B9\u53EA\u63A5\u53D7 POST" });
1341
+ return;
1342
+ }
1343
+ if (typeof req.on !== "function") {
1344
+ send(400, { ok: false, kind: "body", error: "\u65E0\u6CD5\u8BFB\u53D6\u8BF7\u6C42\u4F53" });
1345
+ return;
1346
+ }
1347
+ const chunks = [];
1348
+ req.on("data", (chunk) => chunks.push(chunk));
1349
+ req.on("error", () => send(400, { ok: false, kind: "body", error: "\u8BFB\u53D6\u8BF7\u6C42\u4F53\u5931\u8D25" }));
1350
+ req.on("end", () => {
1351
+ void (async () => {
1352
+ let provider;
1353
+ try {
1354
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
1355
+ provider = parsed?.provider;
1356
+ } catch {
1357
+ send(400, { ok: false, kind: "body", error: "\u8BF7\u6C42\u4F53\u4E0D\u662F\u5408\u6CD5 JSON" });
1358
+ return;
1359
+ }
1360
+ if (typeof provider !== "string" || provider.length === 0) {
1361
+ send(400, { ok: false, kind: "body", error: "\u7F3A\u5C11 provider" });
1362
+ return;
1363
+ }
1364
+ let pending = selfChecks.get(provider);
1365
+ if (pending === void 0) {
1366
+ pending = runSelfCheck(provider);
1367
+ selfChecks.set(provider, pending);
1368
+ void pending.finally(() => {
1369
+ if (selfChecks.get(provider) === pending) selfChecks.delete(provider);
1370
+ });
1371
+ }
1372
+ send(200, await pending);
1373
+ })().catch((error) => {
1374
+ send(500, { ok: false, kind: "internal", error: String(error) });
1375
+ });
1376
+ });
1377
+ }
1378
+ });
1173
1379
  } catch (error) {
1174
1380
  void error;
1175
1381
  }
1176
1382
  });
1383
+ ctx.inject?.(["web"], (wctx) => {
1384
+ const web = wctx?.web;
1385
+ if (web === void 0 || typeof web.search !== "function") return;
1386
+ webAvailable = true;
1387
+ const original = web.search;
1388
+ const patched = function(request, signal) {
1389
+ const route = current.get(WEB_SEARCH_KEY);
1390
+ if (route === void 0) return original.call(this, request, signal);
1391
+ return router.run(
1392
+ {
1393
+ dispatcher: route.dispatcher,
1394
+ provider: WEB_SEARCH_KEY,
1395
+ onHit: () => bump(WEB_SEARCH_KEY),
1396
+ decide: route.decide
1397
+ },
1398
+ () => original.call(this, request, signal)
1399
+ );
1400
+ };
1401
+ web.search = patched;
1402
+ const registerEffect = typeof wctx?.effect === "function" ? wctx.effect.bind(wctx) : ctx.effect?.bind(ctx);
1403
+ registerEffect?.(() => () => {
1404
+ if (web.search === patched) web.search = original;
1405
+ });
1406
+ });
1177
1407
  ctx.effect(() => {
1178
1408
  const stop = ctx.on(
1179
1409
  "llm/stream",
@@ -1182,7 +1412,15 @@ function apply(ctx, config) {
1182
1412
  if (typeof provider !== "string") return next();
1183
1413
  const route = current.get(provider);
1184
1414
  if (route === void 0) return next();
1185
- return router.wrap(route.dispatcher, next);
1415
+ return router.wrap(
1416
+ {
1417
+ dispatcher: route.dispatcher,
1418
+ provider,
1419
+ onHit: () => bump(provider),
1420
+ decide: route.decide
1421
+ },
1422
+ next
1423
+ );
1186
1424
  },
1187
1425
  { global: true }
1188
1426
  );
@@ -1207,7 +1445,8 @@ function apply(ctx, config) {
1207
1445
  provider: entry.provider,
1208
1446
  proxyOrigin: entry.proxyOrigin
1209
1447
  })),
1210
- problems: Object.fromEntries(problems)
1448
+ problems: Object.fromEntries(problems),
1449
+ hits: Object.fromEntries(hits)
1211
1450
  })
1212
1451
  });
1213
1452
  }
@@ -1215,6 +1454,7 @@ export {
1215
1454
  Config,
1216
1455
  PROTOCOLS,
1217
1456
  ProviderProxyRoute,
1457
+ WEB_SEARCH_KEY,
1218
1458
  acquireFetchRouter,
1219
1459
  apply,
1220
1460
  closeTable,