@240xu/dsh-websearch 2.8.2 → 2.8.4

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,338 @@
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
+ import http from "node:http";
15
+ import https from "node:https";
16
+
17
+ const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
18
+ const MAX_REDIRECTS = 5;
19
+
20
+ /** fetch-compatible entry: tunnels via HTTPS_PROXY/HTTP_PROXY when configured. */
21
+ export function egressFetch(url, init = {}) {
22
+ const proxy = proxyFor(url);
23
+ if (proxy === undefined) return fetch(url, init);
24
+ return tunneledFetch(String(url), init, proxy, 0);
25
+ }
26
+
27
+ /** Resolve the proxy URL for a request target (NO_PROXY aware). */
28
+ function proxyFor(url) {
29
+ let target;
30
+ try {
31
+ target = new URL(String(url));
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ if (target.protocol !== "https:" && target.protocol !== "http:") return undefined;
36
+ const env = process.env;
37
+ const proxy = target.protocol === "https:"
38
+ ? env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy
39
+ : env.HTTP_PROXY || env.http_proxy;
40
+ if (proxy === undefined || proxy === "") return undefined;
41
+ if (inNoProxy(target, env.NO_PROXY || env.no_proxy)) return undefined;
42
+ return proxy;
43
+ }
44
+
45
+ /** NO_PROXY matching: "*", bare hosts, leading-dot suffixes, optional :port. */
46
+ function inNoProxy(target, raw) {
47
+ if (raw === undefined || raw === "") return false;
48
+ const host = target.hostname.toLowerCase();
49
+ const port = target.port !== "" ? target.port : target.protocol === "https:" ? "443" : "80";
50
+ for (const piece of String(raw).split(",")) {
51
+ const item = piece.trim().toLowerCase();
52
+ if (item === "") continue;
53
+ if (item === "*") return true;
54
+ const [name, itemPort] = splitHostPort(item);
55
+ if (itemPort !== "" && itemPort !== port) continue;
56
+ const bare = name.replace(/^\./, "");
57
+ if (bare === "" ) continue;
58
+ if (host === bare || host.endsWith("." + bare)) return true;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ function splitHostPort(item) {
64
+ if (item.startsWith("[")) {
65
+ const close = item.indexOf("]");
66
+ if (close === -1) return [item.replace(/^\[/, ""), ""];
67
+ const rest = item.slice(close + 1);
68
+ return [item.slice(1, close), rest.startsWith(":") ? rest.slice(1) : ""];
69
+ }
70
+ const colon = item.lastIndexOf(":");
71
+ if (colon === -1) return [item, ""];
72
+ return [item.slice(0, colon), item.slice(colon + 1)];
73
+ }
74
+
75
+ async function tunneledFetch(url, init, proxy, depth) {
76
+ const target = new URL(url);
77
+ const secure = target.protocol === "https:";
78
+ const method = String(init.method ?? "GET").toUpperCase();
79
+ const headers = normalizeHeaders(init.headers);
80
+ const body = normalizeBody(init.body);
81
+ if (body !== undefined && headers["content-length"] === undefined && headers["transfer-encoding"] === undefined) {
82
+ headers["content-length"] = String(body.length);
83
+ }
84
+ const signal = init.signal;
85
+ throwIfAborted(signal);
86
+
87
+ const response = secure
88
+ ? await httpsViaConnect(target, proxy, method, headers, body, signal)
89
+ : await httpViaProxy(target, proxy, method, headers, body, signal);
90
+
91
+ if (REDIRECT_STATUS.has(response.status)) {
92
+ const location = response.headers.get("location");
93
+ if (location !== null && location !== "") {
94
+ if (init.redirect === "error") {
95
+ await drain(response);
96
+ throw new TypeError(`fetch failed: redirect mode is set to error (${response.status})`);
97
+ }
98
+ if (init.redirect !== "manual" && depth < MAX_REDIRECTS) {
99
+ const next = new URL(location, target);
100
+ const dropBody = response.status === 303
101
+ || ((response.status === 301 || response.status === 302) && method === "POST");
102
+ const nextInit = dropBody
103
+ ? { ...init, method: "GET", body: undefined, headers: withoutBodyHeaders(init.headers) }
104
+ : { ...init };
105
+ await drain(response);
106
+ return egressFetch(next.href, nextInit);
107
+ }
108
+ }
109
+ }
110
+ return response;
111
+ }
112
+
113
+ /* ------------------------------------------------------------------ */
114
+ /* transports */
115
+ /* ------------------------------------------------------------------ */
116
+
117
+ function httpsViaConnect(target, proxyUrl, method, headers, body, signal) {
118
+ const proxy = new URL(proxyUrl);
119
+ const port = target.port !== "" ? Number(target.port) : 443;
120
+ return new Promise((resolve, reject) => {
121
+ const open = [];
122
+ let settled = false;
123
+ const cleanup = () => {
124
+ if (signal !== undefined && typeof signal.removeEventListener === "function") {
125
+ signal.removeEventListener("abort", onAbort);
126
+ }
127
+ };
128
+ const onAbort = () => {
129
+ for (const handle of open) handle.destroy?.();
130
+ };
131
+ const fail = (error) => {
132
+ if (settled) return;
133
+ settled = true;
134
+ cleanup();
135
+ reject(signal?.aborted === true ? abortReason(signal) : error);
136
+ };
137
+ const done = (response) => {
138
+ if (settled) return;
139
+ settled = true;
140
+ cleanup();
141
+ resolve(response);
142
+ };
143
+ if (signal !== undefined && typeof signal.addEventListener === "function") {
144
+ signal.addEventListener("abort", onAbort, { once: true });
145
+ }
146
+
147
+ const connectHeaders = { host: `${target.hostname}:${port}` };
148
+ const proxyAuth = basicProxyAuth(proxy);
149
+ if (proxyAuth !== undefined) connectHeaders["proxy-authorization"] = proxyAuth;
150
+ const connectReq = http.request({
151
+ host: proxy.hostname,
152
+ port: proxy.port !== "" ? Number(proxy.port) : 80,
153
+ method: "CONNECT",
154
+ path: `${target.hostname}:${port}`,
155
+ headers: connectHeaders,
156
+ signal,
157
+ });
158
+ open.push(connectReq);
159
+ connectReq.on("error", (error) => fail(new TypeError(`fetch failed: proxy CONNECT ${sanitize(error)}`, { cause: error })));
160
+ connectReq.on("response", (res) => {
161
+ res.resume();
162
+ fail(new TypeError(`fetch failed: proxy CONNECT rejected with ${res.statusCode}`));
163
+ });
164
+ connectReq.on("connect", (connectRes, socket) => {
165
+ if (connectRes.statusCode !== 200) {
166
+ socket.destroy();
167
+ fail(new TypeError(`fetch failed: proxy CONNECT rejected with ${connectRes.statusCode}`));
168
+ return;
169
+ }
170
+ const request = https.request({
171
+ socket,
172
+ agent: false,
173
+ servername: target.hostname,
174
+ method,
175
+ path: pathWithQuery(target),
176
+ headers,
177
+ });
178
+ open.push(request);
179
+ request.on("error", (error) => fail(new TypeError(`fetch failed: ${sanitize(error)}`, { cause: error })));
180
+ request.on("response", (res) => {
181
+ collect(res).then(done, (error) => fail(error));
182
+ });
183
+ if (signal?.aborted === true) {
184
+ request.destroy();
185
+ fail(abortReason(signal));
186
+ return;
187
+ }
188
+ request.end(body);
189
+ });
190
+ connectReq.end();
191
+ });
192
+ }
193
+
194
+ function httpViaProxy(target, proxyUrl, method, headers, body, signal) {
195
+ const proxy = new URL(proxyUrl);
196
+ return new Promise((resolve, reject) => {
197
+ const request = http.request({
198
+ host: proxy.hostname,
199
+ port: proxy.port !== "" ? Number(proxy.port) : 80,
200
+ method,
201
+ path: target.href,
202
+ headers: { ...headers, host: headers.host ?? target.host },
203
+ signal,
204
+ });
205
+ const fail = (error) => {
206
+ if (settled) return;
207
+ settled = true;
208
+ reject(signal?.aborted === true ? abortReason(signal) : error);
209
+ };
210
+ let settled = false;
211
+ const proxyAuth = basicProxyAuth(proxy);
212
+ if (proxyAuth !== undefined) request.setHeader("proxy-authorization", proxyAuth);
213
+ request.on("error", (error) => fail(new TypeError(`fetch failed: ${sanitize(error)}`, { cause: error })));
214
+ request.on("response", (res) => {
215
+ collect(res).then((response) => {
216
+ if (settled) return;
217
+ settled = true;
218
+ resolve(response);
219
+ }, fail);
220
+ });
221
+ if (signal?.aborted === true) {
222
+ request.destroy();
223
+ fail(abortReason(signal));
224
+ return;
225
+ }
226
+ request.end(body);
227
+ });
228
+ }
229
+
230
+ /* ------------------------------------------------------------------ */
231
+ /* plumbing */
232
+ /* ------------------------------------------------------------------ */
233
+
234
+ function collect(res) {
235
+ return new Promise((resolve, reject) => {
236
+ const chunks = [];
237
+ res.on("data", (chunk) => chunks.push(chunk));
238
+ res.on("end", () => {
239
+ // Null-body statuses (204/304) and empty streams must construct a
240
+ // bodiless Response; an empty Buffer is not null and would throw.
241
+ const payload = chunks.length === 0 ? null : Buffer.concat(chunks);
242
+ resolve(new Response(payload, {
243
+ status: res.statusCode ?? 502,
244
+ headers: headersFromRaw(res),
245
+ }));
246
+ });
247
+ res.on("error", reject);
248
+ });
249
+ }
250
+
251
+ function headersFromRaw(res) {
252
+ const pairs = [];
253
+ const raw = res.rawHeaders ?? [];
254
+ for (let i = 0; i + 1 < raw.length; i += 2) pairs.push([raw[i], raw[i + 1]]);
255
+ return new Headers(pairs);
256
+ }
257
+
258
+ function pathWithQuery(target) {
259
+ return target.pathname + target.search;
260
+ }
261
+
262
+ function normalizeHeaders(raw) {
263
+ const headers = {};
264
+ if (raw === undefined || raw === null) return headers;
265
+ if (typeof raw.forEach === "function" && typeof raw.get === "function") {
266
+ raw.forEach((value, key) => { headers[String(key).toLowerCase()] = String(value); });
267
+ } else if (Array.isArray(raw)) {
268
+ for (const entry of raw) {
269
+ if (Array.isArray(entry) && entry.length >= 2) headers[String(entry[0]).toLowerCase()] = String(entry[1]);
270
+ }
271
+ } else if (typeof raw === "object") {
272
+ for (const [key, value] of Object.entries(raw)) {
273
+ if (value !== undefined) headers[String(key).toLowerCase()] = String(value);
274
+ }
275
+ }
276
+ delete headers["host"];
277
+ return headers;
278
+ }
279
+
280
+ function withoutBodyHeaders(raw) {
281
+ if (raw === undefined || raw === null) return raw;
282
+ const drop = new Set(["content-type", "content-length", "content-encoding"]);
283
+ if (typeof raw.forEach === "function" && typeof raw.get === "function") {
284
+ const copy = new Headers();
285
+ raw.forEach((value, key) => {
286
+ if (!drop.has(String(key).toLowerCase())) copy.append(key, value);
287
+ });
288
+ return copy;
289
+ }
290
+ const copy = {};
291
+ if (Array.isArray(raw)) {
292
+ for (const entry of raw) {
293
+ if (Array.isArray(entry) && entry.length >= 2 && !drop.has(String(entry[0]).toLowerCase())) copy[entry[0]] = entry[1];
294
+ }
295
+ } else if (typeof raw === "object") {
296
+ for (const [key, value] of Object.entries(raw)) {
297
+ if (!drop.has(String(key).toLowerCase())) copy[key] = value;
298
+ }
299
+ }
300
+ return copy;
301
+ }
302
+
303
+ function normalizeBody(body) {
304
+ if (body === undefined || body === null) return undefined;
305
+ if (typeof body === "string") return Buffer.from(body);
306
+ if (Buffer.isBuffer(body)) return body;
307
+ if (body instanceof Uint8Array) return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
308
+ if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return Buffer.from(body.toString());
309
+ return Buffer.from(String(body));
310
+ }
311
+
312
+ function basicProxyAuth(proxy) {
313
+ if (proxy.username === "" && proxy.password === "") return undefined;
314
+ const user = decodeURIComponent(proxy.username);
315
+ const pass = decodeURIComponent(proxy.password);
316
+ return "Basic " + Buffer.from(`${user}:${pass}`).toString("base64");
317
+ }
318
+
319
+ function throwIfAborted(signal) {
320
+ if (signal?.aborted === true) throw abortReason(signal);
321
+ }
322
+
323
+ function abortReason(signal) {
324
+ return signal?.reason !== undefined ? signal.reason : new DOMException("This operation was aborted", "AbortError");
325
+ }
326
+
327
+ function sanitize(error) {
328
+ const raw = error && error.message ? String(error.message) : String(error);
329
+ return raw.replace(/\s+/g, " ").slice(0, 140);
330
+ }
331
+
332
+ async function drain(response) {
333
+ try {
334
+ await response.body?.cancel?.();
335
+ } catch {
336
+ /* already consumed */
337
+ }
338
+ }
@@ -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.2",
3
+ "version": "2.8.4",
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",