@240xu/dsh-websearch 2.8.3 → 2.8.5

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.
@@ -0,0 +1,403 @@
1
+ // util/egress-fetch.js — proxy-aware fetch for kill-switched egress environments.
2
+ //
3
+ // Node's global fetch ignores HTTP_PROXY / HTTPS_PROXY. Containerized
4
+ // deployments commonly rely on those env vars AND block direct connections
5
+ // (egress kill-switch), so every outbound backend request must tunnel through
6
+ // the proxy. This module provides the fetch subset the plugin uses — ok,
7
+ // status, headers.get(), text(), json(), body.cancel(), abort signals,
8
+ // redirect:"error" plus bounded redirect:"follow" — backed by node:http(s)
9
+ // with a proxy CONNECT tunnel and zero dependencies.
10
+ //
11
+ // When no matching proxy env is set, or the target host sits in NO_PROXY, it
12
+ // defers to the native fetch, so behavior everywhere else is unchanged.
13
+ //
14
+ // Optional TLS-egress forwarder: when DSH_EGRESS_FORWARDER is set (a local
15
+ // sidecar exposing a POST /fwd API backed by a Chromium TLS stack), requests
16
+ // are relayed through it with the upstream proxy carried in the sidecar spec
17
+ // (DSH_EGRESS_FORWARDER_PROXY, defaulting to HTTPS_PROXY). This exists because
18
+ // some upstreams (Exa, DuckDuckGo) gate on the client TLS fingerprint (JA3)
19
+ // rather than on the exit IP; the Node TLS fingerprint is rejected where a
20
+ // real browser's is accepted. Unset the env and behavior is byte-identical.
21
+
22
+ import http from "node:http";
23
+ import https from "node:https";
24
+
25
+ const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
26
+ const MAX_REDIRECTS = 5;
27
+
28
+ /** fetch-compatible entry: forwarder first, then HTTPS_PROXY/HTTP_PROXY tunnel. */
29
+ export function egressFetch(url, init = {}) {
30
+ const forwarder = forwarderFor(url);
31
+ if (forwarder !== undefined) return forwarderFetch(String(url), init, forwarder);
32
+ const proxy = proxyFor(url);
33
+ if (proxy === undefined) return fetch(url, init);
34
+ return tunneledFetch(String(url), init, proxy, 0);
35
+ }
36
+
37
+ /** Resolve the TLS-egress forwarder for a request target (NO_PROXY aware). */
38
+ function forwarderFor(url) {
39
+ let target;
40
+ try {
41
+ target = new URL(String(url));
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ if (target.protocol !== "https:" && target.protocol !== "http:") return undefined;
46
+ const env = process.env;
47
+ const endpoint = env.DSH_EGRESS_FORWARDER;
48
+ if (endpoint === undefined || endpoint === "") return undefined;
49
+ if (inNoProxy(target, env.NO_PROXY || env.no_proxy)) return undefined;
50
+ const proxy = env.DSH_EGRESS_FORWARDER_PROXY
51
+ || env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy || "";
52
+ return { endpoint, proxy: proxy === "" ? undefined : proxy };
53
+ }
54
+
55
+ /** Resolve the proxy URL for a request target (NO_PROXY aware). */
56
+ function proxyFor(url) {
57
+ let target;
58
+ try {
59
+ target = new URL(String(url));
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ if (target.protocol !== "https:" && target.protocol !== "http:") return undefined;
64
+ const env = process.env;
65
+ const proxy = target.protocol === "https:"
66
+ ? env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy
67
+ : env.HTTP_PROXY || env.http_proxy;
68
+ if (proxy === undefined || proxy === "") return undefined;
69
+ if (inNoProxy(target, env.NO_PROXY || env.no_proxy)) return undefined;
70
+ return proxy;
71
+ }
72
+
73
+ /** NO_PROXY matching: "*", bare hosts, leading-dot suffixes, optional :port. */
74
+ function inNoProxy(target, raw) {
75
+ if (raw === undefined || raw === "") return false;
76
+ const host = target.hostname.toLowerCase();
77
+ const port = target.port !== "" ? target.port : target.protocol === "https:" ? "443" : "80";
78
+ for (const piece of String(raw).split(",")) {
79
+ const item = piece.trim().toLowerCase();
80
+ if (item === "") continue;
81
+ if (item === "*") return true;
82
+ const [name, itemPort] = splitHostPort(item);
83
+ if (itemPort !== "" && itemPort !== port) continue;
84
+ const bare = name.replace(/^\./, "");
85
+ if (bare === "" ) continue;
86
+ if (host === bare || host.endsWith("." + bare)) return true;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ function splitHostPort(item) {
92
+ if (item.startsWith("[")) {
93
+ const close = item.indexOf("]");
94
+ if (close === -1) return [item.replace(/^\[/, ""), ""];
95
+ const rest = item.slice(close + 1);
96
+ return [item.slice(1, close), rest.startsWith(":") ? rest.slice(1) : ""];
97
+ }
98
+ const colon = item.lastIndexOf(":");
99
+ if (colon === -1) return [item, ""];
100
+ return [item.slice(0, colon), item.slice(colon + 1)];
101
+ }
102
+
103
+ async function tunneledFetch(url, init, proxy, depth) {
104
+ const target = new URL(url);
105
+ const secure = target.protocol === "https:";
106
+ const method = String(init.method ?? "GET").toUpperCase();
107
+ const headers = normalizeHeaders(init.headers);
108
+ const body = normalizeBody(init.body);
109
+ if (body !== undefined && headers["content-length"] === undefined && headers["transfer-encoding"] === undefined) {
110
+ headers["content-length"] = String(body.length);
111
+ }
112
+ const signal = init.signal;
113
+ throwIfAborted(signal);
114
+
115
+ const response = secure
116
+ ? await httpsViaConnect(target, proxy, method, headers, body, signal)
117
+ : await httpViaProxy(target, proxy, method, headers, body, signal);
118
+
119
+ if (REDIRECT_STATUS.has(response.status)) {
120
+ const location = response.headers.get("location");
121
+ if (location !== null && location !== "") {
122
+ if (init.redirect === "error") {
123
+ await drain(response);
124
+ throw new TypeError(`fetch failed: redirect mode is set to error (${response.status})`);
125
+ }
126
+ if (init.redirect !== "manual" && depth < MAX_REDIRECTS) {
127
+ const next = new URL(location, target);
128
+ const dropBody = response.status === 303
129
+ || ((response.status === 301 || response.status === 302) && method === "POST");
130
+ const nextInit = dropBody
131
+ ? { ...init, method: "GET", body: undefined, headers: withoutBodyHeaders(init.headers) }
132
+ : { ...init };
133
+ await drain(response);
134
+ return egressFetch(next.href, nextInit);
135
+ }
136
+ }
137
+ }
138
+ return response;
139
+ }
140
+
141
+ /**
142
+ * Relay through the TLS-egress sidecar: POST /fwd with an
143
+ * x-sidecar-spec base64 JSON header; the sidecar performs the upstream
144
+ * request with its own (Chromium) TLS stack and streams the upstream
145
+ * status/headers/body back verbatim. Upstream dial failures surface as
146
+ * 502 + x-sidecar-error, which we rethrow as fetch-style TypeError.
147
+ */
148
+ async function forwarderFetch(url, init, forwarder) {
149
+ const method = String(init.method ?? "GET").toUpperCase();
150
+ const headers = normalizeHeaders(init.headers);
151
+ const body = normalizeBody(init.body);
152
+ const spec = { url, method, headers, timeout_ms: 90000 };
153
+ if (forwarder.proxy !== undefined) spec.proxy = forwarder.proxy;
154
+ if (init.redirect !== undefined) spec.redirect = init.redirect;
155
+ let response;
156
+ try {
157
+ response = await fetch(forwarder.endpoint, {
158
+ method: "POST",
159
+ headers: {
160
+ "content-type": "application/octet-stream",
161
+ "x-sidecar-spec": Buffer.from(JSON.stringify(spec)).toString("base64"),
162
+ },
163
+ ...(body !== undefined ? { body } : {}),
164
+ ...(init.signal !== undefined ? { signal: init.signal } : {}),
165
+ });
166
+ } catch (error) {
167
+ throw new TypeError(`fetch failed: forwarder unreachable (${sanitize(error)})`, { cause: error });
168
+ }
169
+ const marker = response.headers.get("x-sidecar-error");
170
+ if (response.status === 502 && marker !== null) {
171
+ let detail = "";
172
+ try { detail = (await response.text()).slice(0, 200); } catch {}
173
+ throw new TypeError(`fetch failed: forwarder ${marker}${detail !== "" ? ` (${detail})` : ""}`);
174
+ }
175
+ return response;
176
+ }
177
+
178
+ /* ------------------------------------------------------------------ */
179
+ /* transports */
180
+ /* ------------------------------------------------------------------ */
181
+
182
+ function httpsViaConnect(target, proxyUrl, method, headers, body, signal) {
183
+ const proxy = new URL(proxyUrl);
184
+ const port = target.port !== "" ? Number(target.port) : 443;
185
+ return new Promise((resolve, reject) => {
186
+ const open = [];
187
+ let settled = false;
188
+ const cleanup = () => {
189
+ if (signal !== undefined && typeof signal.removeEventListener === "function") {
190
+ signal.removeEventListener("abort", onAbort);
191
+ }
192
+ };
193
+ const onAbort = () => {
194
+ for (const handle of open) handle.destroy?.();
195
+ };
196
+ const fail = (error) => {
197
+ if (settled) return;
198
+ settled = true;
199
+ cleanup();
200
+ reject(signal?.aborted === true ? abortReason(signal) : error);
201
+ };
202
+ const done = (response) => {
203
+ if (settled) return;
204
+ settled = true;
205
+ cleanup();
206
+ resolve(response);
207
+ };
208
+ if (signal !== undefined && typeof signal.addEventListener === "function") {
209
+ signal.addEventListener("abort", onAbort, { once: true });
210
+ }
211
+
212
+ const connectHeaders = { host: `${target.hostname}:${port}` };
213
+ const proxyAuth = basicProxyAuth(proxy);
214
+ if (proxyAuth !== undefined) connectHeaders["proxy-authorization"] = proxyAuth;
215
+ const connectReq = http.request({
216
+ host: proxy.hostname,
217
+ port: proxy.port !== "" ? Number(proxy.port) : 80,
218
+ method: "CONNECT",
219
+ path: `${target.hostname}:${port}`,
220
+ headers: connectHeaders,
221
+ signal,
222
+ });
223
+ open.push(connectReq);
224
+ connectReq.on("error", (error) => fail(new TypeError(`fetch failed: proxy CONNECT ${sanitize(error)}`, { cause: error })));
225
+ connectReq.on("response", (res) => {
226
+ res.resume();
227
+ fail(new TypeError(`fetch failed: proxy CONNECT rejected with ${res.statusCode}`));
228
+ });
229
+ connectReq.on("connect", (connectRes, socket) => {
230
+ if (connectRes.statusCode !== 200) {
231
+ socket.destroy();
232
+ fail(new TypeError(`fetch failed: proxy CONNECT rejected with ${connectRes.statusCode}`));
233
+ return;
234
+ }
235
+ const request = https.request({
236
+ socket,
237
+ agent: false,
238
+ servername: target.hostname,
239
+ method,
240
+ path: pathWithQuery(target),
241
+ headers,
242
+ });
243
+ open.push(request);
244
+ request.on("error", (error) => fail(new TypeError(`fetch failed: ${sanitize(error)}`, { cause: error })));
245
+ request.on("response", (res) => {
246
+ collect(res).then(done, (error) => fail(error));
247
+ });
248
+ if (signal?.aborted === true) {
249
+ request.destroy();
250
+ fail(abortReason(signal));
251
+ return;
252
+ }
253
+ request.end(body);
254
+ });
255
+ connectReq.end();
256
+ });
257
+ }
258
+
259
+ function httpViaProxy(target, proxyUrl, method, headers, body, signal) {
260
+ const proxy = new URL(proxyUrl);
261
+ return new Promise((resolve, reject) => {
262
+ const request = http.request({
263
+ host: proxy.hostname,
264
+ port: proxy.port !== "" ? Number(proxy.port) : 80,
265
+ method,
266
+ path: target.href,
267
+ headers: { ...headers, host: headers.host ?? target.host },
268
+ signal,
269
+ });
270
+ const fail = (error) => {
271
+ if (settled) return;
272
+ settled = true;
273
+ reject(signal?.aborted === true ? abortReason(signal) : error);
274
+ };
275
+ let settled = false;
276
+ const proxyAuth = basicProxyAuth(proxy);
277
+ if (proxyAuth !== undefined) request.setHeader("proxy-authorization", proxyAuth);
278
+ request.on("error", (error) => fail(new TypeError(`fetch failed: ${sanitize(error)}`, { cause: error })));
279
+ request.on("response", (res) => {
280
+ collect(res).then((response) => {
281
+ if (settled) return;
282
+ settled = true;
283
+ resolve(response);
284
+ }, fail);
285
+ });
286
+ if (signal?.aborted === true) {
287
+ request.destroy();
288
+ fail(abortReason(signal));
289
+ return;
290
+ }
291
+ request.end(body);
292
+ });
293
+ }
294
+
295
+ /* ------------------------------------------------------------------ */
296
+ /* plumbing */
297
+ /* ------------------------------------------------------------------ */
298
+
299
+ function collect(res) {
300
+ return new Promise((resolve, reject) => {
301
+ const chunks = [];
302
+ res.on("data", (chunk) => chunks.push(chunk));
303
+ res.on("end", () => {
304
+ // Null-body statuses (204/304) and empty streams must construct a
305
+ // bodiless Response; an empty Buffer is not null and would throw.
306
+ const payload = chunks.length === 0 ? null : Buffer.concat(chunks);
307
+ resolve(new Response(payload, {
308
+ status: res.statusCode ?? 502,
309
+ headers: headersFromRaw(res),
310
+ }));
311
+ });
312
+ res.on("error", reject);
313
+ });
314
+ }
315
+
316
+ function headersFromRaw(res) {
317
+ const pairs = [];
318
+ const raw = res.rawHeaders ?? [];
319
+ for (let i = 0; i + 1 < raw.length; i += 2) pairs.push([raw[i], raw[i + 1]]);
320
+ return new Headers(pairs);
321
+ }
322
+
323
+ function pathWithQuery(target) {
324
+ return target.pathname + target.search;
325
+ }
326
+
327
+ function normalizeHeaders(raw) {
328
+ const headers = {};
329
+ if (raw === undefined || raw === null) return headers;
330
+ if (typeof raw.forEach === "function" && typeof raw.get === "function") {
331
+ raw.forEach((value, key) => { headers[String(key).toLowerCase()] = String(value); });
332
+ } else if (Array.isArray(raw)) {
333
+ for (const entry of raw) {
334
+ if (Array.isArray(entry) && entry.length >= 2) headers[String(entry[0]).toLowerCase()] = String(entry[1]);
335
+ }
336
+ } else if (typeof raw === "object") {
337
+ for (const [key, value] of Object.entries(raw)) {
338
+ if (value !== undefined) headers[String(key).toLowerCase()] = String(value);
339
+ }
340
+ }
341
+ delete headers["host"];
342
+ return headers;
343
+ }
344
+
345
+ function withoutBodyHeaders(raw) {
346
+ if (raw === undefined || raw === null) return raw;
347
+ const drop = new Set(["content-type", "content-length", "content-encoding"]);
348
+ if (typeof raw.forEach === "function" && typeof raw.get === "function") {
349
+ const copy = new Headers();
350
+ raw.forEach((value, key) => {
351
+ if (!drop.has(String(key).toLowerCase())) copy.append(key, value);
352
+ });
353
+ return copy;
354
+ }
355
+ const copy = {};
356
+ if (Array.isArray(raw)) {
357
+ for (const entry of raw) {
358
+ if (Array.isArray(entry) && entry.length >= 2 && !drop.has(String(entry[0]).toLowerCase())) copy[entry[0]] = entry[1];
359
+ }
360
+ } else if (typeof raw === "object") {
361
+ for (const [key, value] of Object.entries(raw)) {
362
+ if (!drop.has(String(key).toLowerCase())) copy[key] = value;
363
+ }
364
+ }
365
+ return copy;
366
+ }
367
+
368
+ function normalizeBody(body) {
369
+ if (body === undefined || body === null) return undefined;
370
+ if (typeof body === "string") return Buffer.from(body);
371
+ if (Buffer.isBuffer(body)) return body;
372
+ if (body instanceof Uint8Array) return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
373
+ if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return Buffer.from(body.toString());
374
+ return Buffer.from(String(body));
375
+ }
376
+
377
+ function basicProxyAuth(proxy) {
378
+ if (proxy.username === "" && proxy.password === "") return undefined;
379
+ const user = decodeURIComponent(proxy.username);
380
+ const pass = decodeURIComponent(proxy.password);
381
+ return "Basic " + Buffer.from(`${user}:${pass}`).toString("base64");
382
+ }
383
+
384
+ function throwIfAborted(signal) {
385
+ if (signal?.aborted === true) throw abortReason(signal);
386
+ }
387
+
388
+ function abortReason(signal) {
389
+ return signal?.reason !== undefined ? signal.reason : new DOMException("This operation was aborted", "AbortError");
390
+ }
391
+
392
+ function sanitize(error) {
393
+ const raw = error && error.message ? String(error.message) : String(error);
394
+ return raw.replace(/\s+/g, " ").slice(0, 140);
395
+ }
396
+
397
+ async function drain(response) {
398
+ try {
399
+ await response.body?.cancel?.();
400
+ } catch {
401
+ /* already consumed */
402
+ }
403
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { WebError } from "@deepseek-ai/dsh-web";
9
+ import { egressFetch } from "./egress-fetch.js";
9
10
 
10
11
  const UA = "dsh-websearch/2.3";
11
12
  const PROTOCOL_VERSION = "2025-06-18";
@@ -41,7 +42,7 @@ async function ensureSession(url, signal) {
41
42
 
42
43
  let initResponse;
43
44
  try {
44
- initResponse = await fetch(url, {
45
+ initResponse = await egressFetch(url, {
45
46
  method: "POST",
46
47
  headers: {
47
48
  "Content-Type": "application/json",
@@ -75,7 +76,7 @@ async function ensureSession(url, signal) {
75
76
  };
76
77
 
77
78
  try {
78
- const notifyResponse = await fetch(url, {
79
+ const notifyResponse = await egressFetch(url, {
79
80
  method: "POST",
80
81
  headers: {
81
82
  "Content-Type": "application/json",
@@ -161,7 +162,7 @@ export async function mcpCall(url, toolName, args, extraHeaders = {}, signal) {
161
162
 
162
163
  let response;
163
164
  try {
164
- response = await fetch(url, {
165
+ response = await egressFetch(url, {
165
166
  method: "POST",
166
167
  headers: {
167
168
  "Content-Type": "application/json",
@@ -186,7 +187,7 @@ export async function mcpCall(url, toolName, args, extraHeaders = {}, signal) {
186
187
  // Retry with fresh session
187
188
  const newSessionId = await ensureSession(url, signal);
188
189
  try {
189
- response = await fetch(url, {
190
+ response = await egressFetch(url, {
190
191
  method: "POST",
191
192
  headers: {
192
193
  "Content-Type": "application/json",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@240xu/dsh-websearch",
3
- "version": "2.8.3",
3
+ "version": "2.8.5",
4
4
  "description": "Unified web search provider for DeepSeek Harness (ctx.web): 11 backends — Exa + Parallel + DDG + SearXNG (keyless), DeepSeek + Anthropic + OpenAI + Brave + Tavily + Serper + Mojeek (key-gated via Settings credentials). Registers provider id \"unified\" for zero-config web_search. 统一网页搜索聚合:11 后端并发扇出、去重合并,零依赖。See CHANGELOG.md for version history.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",