dsh-flash-proxy 0.0.0-stage → 0.1.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.
- package/LICENSE +201 -0
- package/README.md +50 -3
- package/README.zh-CN.md +86 -0
- package/cordis.patch.yml +4 -0
- package/dist/index.js +714 -0
- package/lib/client.js +999 -0
- package/package.json +59 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
// Default export only (export default Schema); there is no named Schema.
|
|
4
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
5
|
+
export const name = 'dsh-flash-proxy';
|
|
6
|
+
// No host-side hard dependencies; all services are injected lazily.
|
|
7
|
+
export const inject = [];
|
|
8
|
+
// ── Constants ─────────────────────────────────────────────────────────────
|
|
9
|
+
const DEFAULT_MODE = 'all-proxy';
|
|
10
|
+
const DEFAULT_CUSTOM = '';
|
|
11
|
+
/**
|
|
12
|
+
* Default test target: the canonical "is there a working network path"
|
|
13
|
+
* endpoint. Returns an empty 204, so it measures the path and nothing else —
|
|
14
|
+
* and it is unreachable without a working proxy on networks that need one,
|
|
15
|
+
* which is exactly the signal the test is meant to produce.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
|
18
|
+
/** Hard ceiling on a single test; also reported in the diagnostics payload. */
|
|
19
|
+
const TEST_TIMEOUT_MS = 10000;
|
|
20
|
+
/** Redirect hops followed before giving up (the chain is reported either way). */
|
|
21
|
+
const MAX_REDIRECTS = 5;
|
|
22
|
+
/** Bytes of response body echoed back for inspection. */
|
|
23
|
+
const BODY_SNIPPET_LIMIT = 200;
|
|
24
|
+
/** Domains that bypass the proxy when proxyMode is 'api-bypass'. */
|
|
25
|
+
const API_BYPASS_DOMAINS = 'api.deepseek.com,chat.deepseek.com';
|
|
26
|
+
/**
|
|
27
|
+
* The ctx service DSH publishes with the launch-environment snapshot it
|
|
28
|
+
* resolved the boot-time proxy policy from. That snapshot merges three layers
|
|
29
|
+
* (process | project-env | user-env) and structurally satisfies the
|
|
30
|
+
* EnvLookup this plugin hands back to dsh-http-proxy.
|
|
31
|
+
*/
|
|
32
|
+
const LAUNCH_ENVIRONMENT_SERVICE = 'launchEnvironment';
|
|
33
|
+
/**
|
|
34
|
+
* The plugin's Config schema — the host's composition defaults, exported so
|
|
35
|
+
* the Cordis loader publishes it as runtime.Config.
|
|
36
|
+
*
|
|
37
|
+
* This is not a cosmetic nicety. dsh-settings resolves a namespace's editable
|
|
38
|
+
* form from entry.fiber.runtime.Config (its schema(entry) reads exactly
|
|
39
|
+
* that), so a plugin without an exported Config is NOT configurable by the
|
|
40
|
+
* native configuration editor — and a client preference write through
|
|
41
|
+
* settings.update('dsh-flash-proxy', …) is refused with No configurable plugin
|
|
42
|
+
* entry "dsh-flash-proxy".
|
|
43
|
+
*
|
|
44
|
+
* Every field is marked .volatile(): live-editable without plugin restart.
|
|
45
|
+
* The settings configuration editor only shows volatile fields; ordinary
|
|
46
|
+
* (non-volatile) config requires a Cordis configuration file edit and a
|
|
47
|
+
* restart. Since all dsh-flash-proxy settings are user preferences the client
|
|
48
|
+
* writes through ctx.remote.settings, they must all be volatile.
|
|
49
|
+
*/
|
|
50
|
+
export const Config = Schema.object({
|
|
51
|
+
proxyEnabled: Schema.boolean().default(true).volatile(),
|
|
52
|
+
proxyMode: Schema.string().default(DEFAULT_MODE).volatile(),
|
|
53
|
+
customNoProxy: Schema.string().default(DEFAULT_CUSTOM).volatile(),
|
|
54
|
+
testUrl: Schema.string().default(DEFAULT_TEST_URL).volatile(),
|
|
55
|
+
// Keep the old field so legacy clients don't break; migrated on read.
|
|
56
|
+
useProxy: Schema.boolean().default(true).volatile(),
|
|
57
|
+
});
|
|
58
|
+
/** Map a proxyMode (+ optional customNoProxy) to the actual NO_PROXY value. */
|
|
59
|
+
function resolveNoProxy(mode, custom) {
|
|
60
|
+
switch (mode) {
|
|
61
|
+
case 'all-proxy': return undefined; // no bypass → all traffic proxied
|
|
62
|
+
case 'api-bypass': return API_BYPASS_DOMAINS;
|
|
63
|
+
case 'all-bypass': return '*';
|
|
64
|
+
case 'custom': {
|
|
65
|
+
// The only user-supplied value here. A blank one means "no bypass
|
|
66
|
+
// entries", i.e. the same routing as all-proxy — so clear the variable
|
|
67
|
+
// rather than publishing NO_PROXY='', which would leave a set-but-empty
|
|
68
|
+
// variable in the environment for spawned children to read. (Routing is
|
|
69
|
+
// identical either way: dsh-http-proxy's parser drops empty entries.)
|
|
70
|
+
const value = (custom || '').trim();
|
|
71
|
+
return value === '' ? undefined : value;
|
|
72
|
+
}
|
|
73
|
+
default: return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Disposer for the dispatcher this plugin installed.
|
|
78
|
+
*
|
|
79
|
+
* installProxyFromEnvironment returns one and restores both the global
|
|
80
|
+
* dispatcher and the module's policy state. Dropping it — as this code used to
|
|
81
|
+
* — leaks one ProxyAgent and its whole socket pool per mode change.
|
|
82
|
+
*/
|
|
83
|
+
let _disposeProxyPolicy = null;
|
|
84
|
+
/**
|
|
85
|
+
* The last (mode, custom) pair this plugin actually applied, joined by a NUL
|
|
86
|
+
* so ("a","b\0c") cannot collide with ("a\0b","c").
|
|
87
|
+
*
|
|
88
|
+
* Two paths can reach applyProxyEnv: the initial startup apply below and the
|
|
89
|
+
* loader/volatile-update handler after a settings edit. Both are async — each
|
|
90
|
+
* suspends on await loadProxyModule() *before* it touches
|
|
91
|
+
* _disposeProxyPolicy — so without a guard the later caller would overwrite
|
|
92
|
+
* the field and the earlier disposer would be dropped, leaking one ProxyAgent
|
|
93
|
+
* and its socket pool. The duplicate would also log the pair twice, so the
|
|
94
|
+
* proxy log could no longer distinguish a real mode switch from startup noise.
|
|
95
|
+
*
|
|
96
|
+
* The guard is assigned before the first await on purpose — that is what
|
|
97
|
+
* makes the second caller in the same tick a no-op — and cleared again on the
|
|
98
|
+
* paths that do not end in an install, so a transient failure still retries.
|
|
99
|
+
*/
|
|
100
|
+
let _appliedProxyKey = null;
|
|
101
|
+
let _proxyModulePromise = null;
|
|
102
|
+
/**
|
|
103
|
+
* Load @deepseek-ai/dsh-http-proxy, once, from the instance DSH itself uses.
|
|
104
|
+
*
|
|
105
|
+
* Two traps live here, and both previously made the entire proxy feature a
|
|
106
|
+
* silent no-op:
|
|
107
|
+
*
|
|
108
|
+
* 1. require does not exist. This half is ESM, so every require(...) threw
|
|
109
|
+
* ReferenceError: require is not defined into a surrounding try/catch —
|
|
110
|
+
* which is why the failure only ever surfaced as a stray string in the
|
|
111
|
+
* client's diagnostics.
|
|
112
|
+
* 2. The package is not resolvable from here at all. It ships nested inside the
|
|
113
|
+
* DSH installation (<dsh>/node_modules/@deepseek-ai/dsh-http-proxy) and is
|
|
114
|
+
* not a dependency of this plugin.
|
|
115
|
+
*
|
|
116
|
+
* Resolving it is not merely convenience. The module keeps the resolved policy
|
|
117
|
+
* in *module-level* state (active/installed) that only
|
|
118
|
+
* installProxyFromEnvironment writes, and proxyRouteFor reads. A second
|
|
119
|
+
* copy of the module would therefore answer "direct" for every URL forever,
|
|
120
|
+
* while also installing a dispatcher that DSH's own proxyRouteFor cannot see.
|
|
121
|
+
* So: one cached handle, resolved through DSH's own entry point, which is the
|
|
122
|
+
* exact module instance DSH booted with.
|
|
123
|
+
*/
|
|
124
|
+
function loadProxyModule() {
|
|
125
|
+
if (_proxyModulePromise)
|
|
126
|
+
return _proxyModulePromise;
|
|
127
|
+
_proxyModulePromise = (async () => {
|
|
128
|
+
// Widened to string on purpose: a literal would make TypeScript try to
|
|
129
|
+
// resolve a package that is deliberately not a dependency of this plugin.
|
|
130
|
+
const specifier = '@deepseek-ai/dsh-http-proxy';
|
|
131
|
+
// Preferred: an ordinary resolution, for any setup that installs it for us.
|
|
132
|
+
try {
|
|
133
|
+
return (await import(specifier));
|
|
134
|
+
}
|
|
135
|
+
catch (_) { /* fall through to DSH's own copy */ }
|
|
136
|
+
const entry = process.argv[1];
|
|
137
|
+
if (!entry) {
|
|
138
|
+
console.warn('[dsh-flash-proxy] cannot locate the DSH entry point; proxy control unavailable');
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const resolved = createRequire(entry).resolve(specifier);
|
|
143
|
+
console.log('[dsh-flash-proxy] dsh-http-proxy resolved to ' + resolved);
|
|
144
|
+
return (await import(pathToFileURL(resolved).href));
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
console.warn('[dsh-flash-proxy] could not load ' + specifier + ': ' + (e?.message || e));
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
})();
|
|
151
|
+
return _proxyModulePromise;
|
|
152
|
+
}
|
|
153
|
+
/** An EnvLookup over process.env — the fallback when no snapshot is provided. */
|
|
154
|
+
function processEnvLookup() {
|
|
155
|
+
return {
|
|
156
|
+
get(name) {
|
|
157
|
+
const value = process.env[name];
|
|
158
|
+
return value !== undefined && value !== '' ? { value } : undefined;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** Send a JSON response with no-store cache control. */
|
|
163
|
+
function sendJson(res, status, payload) {
|
|
164
|
+
res.statusCode = status;
|
|
165
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
166
|
+
res.setHeader('cache-control', 'no-store');
|
|
167
|
+
res.end(JSON.stringify(payload));
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Read an optional JSON request body, bounded so a client cannot feed the
|
|
171
|
+
* host an unbounded buffer. Returns null for an empty, oversized, or
|
|
172
|
+
* unparseable body — callers treat that as "no override supplied".
|
|
173
|
+
*/
|
|
174
|
+
async function readJsonBody(req, limit = 4096) {
|
|
175
|
+
try {
|
|
176
|
+
const chunks = [];
|
|
177
|
+
let size = 0;
|
|
178
|
+
for await (const chunk of req) {
|
|
179
|
+
size += chunk.length;
|
|
180
|
+
if (size > limit)
|
|
181
|
+
return null;
|
|
182
|
+
chunks.push(chunk);
|
|
183
|
+
}
|
|
184
|
+
if (chunks.length === 0)
|
|
185
|
+
return null;
|
|
186
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
187
|
+
}
|
|
188
|
+
catch (_) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// ── Main plugin entry ─────────────────────────────────────────────────────
|
|
193
|
+
export function apply(ctx, config) {
|
|
194
|
+
/** The launch-environment snapshot DSH resolved the boot-time policy from. */
|
|
195
|
+
function launchEnvironment() {
|
|
196
|
+
try {
|
|
197
|
+
const svc = ctx.get ? ctx.get(LAUNCH_ENVIRONMENT_SERVICE) : undefined;
|
|
198
|
+
return svc && typeof svc.get === 'function' ? svc : null;
|
|
199
|
+
}
|
|
200
|
+
catch (_) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Read a launch-environment variable the way the policy resolved it: the
|
|
206
|
+
* launch snapshot first (it merges process / project-env / user-env),
|
|
207
|
+
* process.env as fallback. The proxy variables are the main caller.
|
|
208
|
+
*/
|
|
209
|
+
function readLaunchEnv(names) {
|
|
210
|
+
const snapshot = launchEnvironment();
|
|
211
|
+
for (const name of names) {
|
|
212
|
+
const fromSnapshot = snapshot ? snapshot.get(name) : undefined;
|
|
213
|
+
if (fromSnapshot && fromSnapshot.value)
|
|
214
|
+
return fromSnapshot.value;
|
|
215
|
+
const raw = process.env[name];
|
|
216
|
+
if (raw)
|
|
217
|
+
return raw;
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The per-class proxy variables currently in force, read the same way the
|
|
223
|
+
* policy does (launch snapshot first, process.env as fallback). Unlike the
|
|
224
|
+
* single httpProxy field — which reports the *first* value found, mirroring
|
|
225
|
+
* how undici falls back https→http — this keeps each family's value distinct,
|
|
226
|
+
* so a UI can list HTTP_PROXY / HTTPS_PROXY / ALL_PROXY verbatim. https
|
|
227
|
+
* checks HTTPS_PROXY first, then HTTP, matching dsh-http-proxy's fallback;
|
|
228
|
+
* http and all are reported exactly as found.
|
|
229
|
+
*/
|
|
230
|
+
function proxyEnvSummary() {
|
|
231
|
+
return {
|
|
232
|
+
// HTTPS falls back to the HTTP proxy when no HTTPS_PROXY is set — that is
|
|
233
|
+
// an undici behaviour (https uses https_proxy, else http_proxy) and the
|
|
234
|
+
// display should mirror what routing actually does.
|
|
235
|
+
https: readLaunchEnv(['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']),
|
|
236
|
+
http: readLaunchEnv(['HTTP_PROXY', 'http_proxy']),
|
|
237
|
+
all: readLaunchEnv(['ALL_PROXY', 'all_proxy']),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Resolve the effective proxy mode.
|
|
242
|
+
*
|
|
243
|
+
* The global master switch (proxyEnabled) takes precedence: when it is off,
|
|
244
|
+
* the proxy is disabled entirely and every request goes direct (all-bypass).
|
|
245
|
+
* Otherwise the legacy useProxy migration applies — if proxyMode sits at its
|
|
246
|
+
* default but useProxy was explicitly set, the old boolean takes over.
|
|
247
|
+
*/
|
|
248
|
+
function resolveMode() {
|
|
249
|
+
// Global master off → proxy disabled entirely (all direct).
|
|
250
|
+
const enabled = config.proxyEnabled?.get() !== false;
|
|
251
|
+
if (!enabled)
|
|
252
|
+
return 'all-bypass';
|
|
253
|
+
let mode = config.proxyMode.get() || DEFAULT_MODE;
|
|
254
|
+
if (!config.proxyMode.get() && typeof config.useProxy?.get() === 'boolean') {
|
|
255
|
+
mode = config.useProxy.get() ? 'all-proxy' : 'all-bypass';
|
|
256
|
+
}
|
|
257
|
+
return mode;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Publish the chosen bypass list and re-install the process-wide dispatcher.
|
|
261
|
+
*
|
|
262
|
+
* Writing process.env.NO_PROXY is necessary but nowhere near sufficient:
|
|
263
|
+
* undici's global dispatcher routes by the ProxyPolicy object it was handed
|
|
264
|
+
* at install time and never re-reads the environment, so only a re-install
|
|
265
|
+
* changes actual routing. The environment write exists for the consumers that
|
|
266
|
+
* *do* read it — spawned children, and node:http's proxyEnv.
|
|
267
|
+
*/
|
|
268
|
+
async function applyProxyEnv(mode, custom) {
|
|
269
|
+
// Idempotence guard — see _appliedProxyKey. Set before any await so the
|
|
270
|
+
// duplicate startup caller is a no-op rather than a racing second install.
|
|
271
|
+
const key = mode + '\u0000' + custom;
|
|
272
|
+
if (key === _appliedProxyKey)
|
|
273
|
+
return;
|
|
274
|
+
_appliedProxyKey = key;
|
|
275
|
+
const noProxy = resolveNoProxy(mode, custom);
|
|
276
|
+
if (noProxy === undefined) {
|
|
277
|
+
delete process.env.NO_PROXY;
|
|
278
|
+
delete process.env.no_proxy;
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
process.env.NO_PROXY = noProxy;
|
|
282
|
+
process.env.no_proxy = noProxy;
|
|
283
|
+
}
|
|
284
|
+
console.log('[dsh-flash-proxy] proxy mode=' + mode + ' (NO_PROXY=' + (noProxy ?? '<removed>') + ')');
|
|
285
|
+
const mod = await loadProxyModule();
|
|
286
|
+
if (!mod) {
|
|
287
|
+
_appliedProxyKey = null;
|
|
288
|
+
console.warn('[dsh-flash-proxy] proxy module unavailable — routing is unchanged (the mode now affects child processes only)');
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
// Base the policy on DSH's own snapshot so that nothing but the bypass list
|
|
292
|
+
// changes. Resolving from process.env would silently disagree with the
|
|
293
|
+
// policy DSH installed: its snapshot also merges the project-env and
|
|
294
|
+
// user-env layers, which process.env knows nothing about.
|
|
295
|
+
const snapshot = launchEnvironment();
|
|
296
|
+
const base = snapshot || processEnvLookup();
|
|
297
|
+
const envLookup = {
|
|
298
|
+
get(name) {
|
|
299
|
+
// undici reads the lowercase spelling first, so both are owned here.
|
|
300
|
+
if (name === 'NO_PROXY' || name === 'no_proxy') {
|
|
301
|
+
return noProxy === undefined ? undefined : { value: noProxy };
|
|
302
|
+
}
|
|
303
|
+
return base.get(name);
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
try {
|
|
307
|
+
// Release the previous install before taking a new one, otherwise every
|
|
308
|
+
// mode change stacks another dispatcher on top of the last.
|
|
309
|
+
if (_disposeProxyPolicy) {
|
|
310
|
+
try {
|
|
311
|
+
await _disposeProxyPolicy();
|
|
312
|
+
}
|
|
313
|
+
catch (_) { /* already released */ }
|
|
314
|
+
_disposeProxyPolicy = null;
|
|
315
|
+
}
|
|
316
|
+
_disposeProxyPolicy = await mod.installProxyFromEnvironment(envLookup, (message) => {
|
|
317
|
+
console.warn('[dsh-flash-proxy] proxy install warning: ' + message);
|
|
318
|
+
});
|
|
319
|
+
console.log('[dsh-flash-proxy] undici global dispatcher re-installed (env source=' +
|
|
320
|
+
(snapshot ? 'launchEnvironment' : 'process.env') + ')');
|
|
321
|
+
}
|
|
322
|
+
catch (e) {
|
|
323
|
+
// Let the next change retry: nothing was installed, so nothing is in force.
|
|
324
|
+
_appliedProxyKey = null;
|
|
325
|
+
console.warn('[dsh-flash-proxy] could not re-install proxy dispatcher:', e?.message || e);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Ask dsh-http-proxy how it would route url.
|
|
330
|
+
*
|
|
331
|
+
* proxyRouteFor takes a URL object. Handed a string it does not throw — it
|
|
332
|
+
* quietly answers "direct", which is how this plugin came to report 直连 for
|
|
333
|
+
* every request it ever tested.
|
|
334
|
+
*/
|
|
335
|
+
async function proxyRouteForUrl(url) {
|
|
336
|
+
const mod = await loadProxyModule();
|
|
337
|
+
if (!mod)
|
|
338
|
+
return { proxied: false, error: 'dsh-http-proxy is not loadable from this plugin' };
|
|
339
|
+
try {
|
|
340
|
+
return { proxied: mod.proxyRouteFor(new URL(url))?.proxied === true, error: null };
|
|
341
|
+
}
|
|
342
|
+
catch (e) {
|
|
343
|
+
return { proxied: false, error: e?.message || String(e) };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Resolve the test target.
|
|
348
|
+
*
|
|
349
|
+
* An explicit override (from the request body) wins over the stored
|
|
350
|
+
* setting, so the URL the client is displaying is exactly the URL probed —
|
|
351
|
+
* no dependency on the settings write having landed first.
|
|
352
|
+
*/
|
|
353
|
+
function resolveTestUrl(override) {
|
|
354
|
+
const fromOverride = typeof override === 'string' ? override.trim() : '';
|
|
355
|
+
const raw = fromOverride || String(config.testUrl.get() || '').trim();
|
|
356
|
+
return raw || DEFAULT_TEST_URL;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* How dsh-http-proxy would route url, plus the env it decides from.
|
|
360
|
+
*
|
|
361
|
+
* probeRoute: false is for callers that already rejected the URL: routing is
|
|
362
|
+
* moot then, and reporting routeError: "Invalid URL" alongside the caller's
|
|
363
|
+
* own InvalidTestUrl only prints the same message twice.
|
|
364
|
+
*/
|
|
365
|
+
async function describeProxyRoute(url, probeRoute = true) {
|
|
366
|
+
const mode = resolveMode();
|
|
367
|
+
const custom = config.customNoProxy.get() || '';
|
|
368
|
+
const route = probeRoute
|
|
369
|
+
? await proxyRouteForUrl(url)
|
|
370
|
+
: { proxied: false, error: null };
|
|
371
|
+
return {
|
|
372
|
+
mode,
|
|
373
|
+
// What this plugin published for the current mode: the value that governs
|
|
374
|
+
// routing once the dispatcher has been re-installed.
|
|
375
|
+
noProxy: resolveNoProxy(mode, custom) ?? null,
|
|
376
|
+
httpProxy: readLaunchEnv(['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']),
|
|
377
|
+
proxyEnv: proxyEnvSummary(),
|
|
378
|
+
proxied: route.proxied,
|
|
379
|
+
routeError: route.error,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Diagnose outbound connectivity to url.
|
|
384
|
+
*
|
|
385
|
+
* Two deliberate choices, both about *diagnosis* rather than connectivity:
|
|
386
|
+
*
|
|
387
|
+
* - redirect: 'manual' with a hand-rolled hop loop, so the redirect chain is
|
|
388
|
+
* recorded instead of silently followed. "302 to somewhere unreachable" is
|
|
389
|
+
* a different failure from "connection refused", and the old single-shot
|
|
390
|
+
* redirect: 'follow' made them indistinguishable.
|
|
391
|
+
* - Never throws. Every failure is returned as data, because the caller has
|
|
392
|
+
* to render it either way, and the interesting part (cause.code — e.g.
|
|
393
|
+
* ENOTFOUND, UND_ERR_CONNECT_TIMEOUT, DEPTH_ZERO_SELF_SIGNED_CERT)
|
|
394
|
+
* only exists on the nested cause of undici's TypeError: fetch failed.
|
|
395
|
+
*/
|
|
396
|
+
async function runConnectionTest(url) {
|
|
397
|
+
const started = Date.now();
|
|
398
|
+
const proxy = await describeProxyRoute(url);
|
|
399
|
+
const redirects = [];
|
|
400
|
+
/** Assemble the payload so every exit path reports the same shape. */
|
|
401
|
+
const report = (extra) => ({
|
|
402
|
+
url,
|
|
403
|
+
proxy,
|
|
404
|
+
timeoutMs: TEST_TIMEOUT_MS,
|
|
405
|
+
redirects,
|
|
406
|
+
elapsedMs: Date.now() - started,
|
|
407
|
+
...extra,
|
|
408
|
+
});
|
|
409
|
+
let current = url;
|
|
410
|
+
let resp = null;
|
|
411
|
+
let redirectLimitHit = false;
|
|
412
|
+
try {
|
|
413
|
+
for (let hop = 0;; hop++) {
|
|
414
|
+
resp = await fetch(current, {
|
|
415
|
+
method: 'GET',
|
|
416
|
+
redirect: 'manual',
|
|
417
|
+
signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
|
418
|
+
});
|
|
419
|
+
const location = resp.headers.get('location');
|
|
420
|
+
if (!(resp.status >= 300 && resp.status < 400 && location))
|
|
421
|
+
break;
|
|
422
|
+
let next = String(location);
|
|
423
|
+
try {
|
|
424
|
+
next = new URL(next, current).href;
|
|
425
|
+
}
|
|
426
|
+
catch (_) { /* keep raw value */ }
|
|
427
|
+
redirects.push({ hop: hop + 1, from: current, status: resp.status, to: next });
|
|
428
|
+
if (hop >= MAX_REDIRECTS) {
|
|
429
|
+
redirectLimitHit = true;
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
current = next;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
catch (e) {
|
|
436
|
+
const cause = e?.cause;
|
|
437
|
+
return report({
|
|
438
|
+
ok: false,
|
|
439
|
+
finalUrl: current,
|
|
440
|
+
headersMs: Date.now() - started,
|
|
441
|
+
status: 0,
|
|
442
|
+
statusText: '',
|
|
443
|
+
contentType: '',
|
|
444
|
+
contentLength: null,
|
|
445
|
+
bodyMs: 0,
|
|
446
|
+
bodyBytes: 0,
|
|
447
|
+
bodySnippet: null,
|
|
448
|
+
redirectLimitHit,
|
|
449
|
+
error: {
|
|
450
|
+
name: e?.name || 'Error',
|
|
451
|
+
message: e?.message || String(e),
|
|
452
|
+
code: e?.code || null,
|
|
453
|
+
causeName: cause?.name || null,
|
|
454
|
+
causeMessage: cause?.message || null,
|
|
455
|
+
causeCode: cause?.code || null,
|
|
456
|
+
causeErrno: cause?.errno ?? null,
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
const headersMs = Date.now() - started;
|
|
461
|
+
const contentType = resp.headers.get('content-type') || '';
|
|
462
|
+
const contentLength = resp.headers.get('content-length') || null;
|
|
463
|
+
// Read the body too, so the timing covers the whole exchange and a proxy's
|
|
464
|
+
// own "blocked" page can be inspected rather than guessed at.
|
|
465
|
+
let bodyBytes = 0;
|
|
466
|
+
let bodySnippet = null;
|
|
467
|
+
const bodyStart = Date.now();
|
|
468
|
+
try {
|
|
469
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
470
|
+
bodyBytes = buf.byteLength;
|
|
471
|
+
const textual = !contentType || /text|json|xml|javascript|html/i.test(contentType);
|
|
472
|
+
if (buf.byteLength > 0 && textual) {
|
|
473
|
+
bodySnippet = buf.toString('utf8', 0, Math.min(buf.byteLength, BODY_SNIPPET_LIMIT));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
catch (_) { /* body is optional — headers already prove the path */ }
|
|
477
|
+
const bodyMs = Date.now() - bodyStart;
|
|
478
|
+
// Any HTTP response means the network path works — 401/404 included. The
|
|
479
|
+
// status is reported verbatim so the caller can judge it.
|
|
480
|
+
return report({
|
|
481
|
+
ok: true,
|
|
482
|
+
finalUrl: current,
|
|
483
|
+
headersMs,
|
|
484
|
+
status: resp.status,
|
|
485
|
+
statusText: resp.statusText || '',
|
|
486
|
+
contentType,
|
|
487
|
+
contentLength,
|
|
488
|
+
bodyMs,
|
|
489
|
+
bodyBytes,
|
|
490
|
+
bodySnippet,
|
|
491
|
+
redirectLimitHit,
|
|
492
|
+
error: null,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
// ── Settings registration ──────────────────────────────────────────────
|
|
496
|
+
// When the settings service is available, register the dsh-flash-proxy
|
|
497
|
+
// namespace's page policy. Volatile fields in the exported Config schema
|
|
498
|
+
// are what make this plugin's settings editable without restart —
|
|
499
|
+
// settings.configure tells the settings UI to show a form for this
|
|
500
|
+
// instance; it does not register a schema (that is Config's job).
|
|
501
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
502
|
+
settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber));
|
|
503
|
+
});
|
|
504
|
+
// ── Volatile-update handler (proxy paths only) ─────────────────────────
|
|
505
|
+
// React to volatile config updates in-place. The loader's _commitVolatile()
|
|
506
|
+
// updates the Volatile<T> references in config and then emits
|
|
507
|
+
// loader/volatile-update with the paths that changed. This plugin
|
|
508
|
+
// subscribes to proxy paths only.
|
|
509
|
+
const relevant = (p) => p.length === 1;
|
|
510
|
+
ctx.on('loader/volatile-update', (paths) => {
|
|
511
|
+
const proxyPaths = ['proxyEnabled', 'proxyMode', 'customNoProxy', 'useProxy'];
|
|
512
|
+
if (!paths.some((p) => relevant(p) && proxyPaths.includes(p[0])))
|
|
513
|
+
return;
|
|
514
|
+
try {
|
|
515
|
+
const mode = resolveMode();
|
|
516
|
+
applyProxyEnv(mode, config.customNoProxy.get() || '');
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
console.error('[dsh-flash-proxy] failed to update proxy setting:', e);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
// ── Initial proxy state apply ──────────────────────────────────────────
|
|
523
|
+
// Apply the initial proxy state immediately
|
|
524
|
+
try {
|
|
525
|
+
const mode = resolveMode();
|
|
526
|
+
applyProxyEnv(mode, config.customNoProxy.get() || '');
|
|
527
|
+
}
|
|
528
|
+
catch (_) { }
|
|
529
|
+
// ── HTTP API routes ────────────────────────────────────────────────────
|
|
530
|
+
// The webServer type augmentation lives in @deepseek-ai/dsh-host-webserver
|
|
531
|
+
// which is not a direct dependency; cast through any for the register calls.
|
|
532
|
+
ctx.inject(['webServer'], (wsCtx) => {
|
|
533
|
+
// GET /plugins/dsh-flash-proxy/proxy-status
|
|
534
|
+
// Returns the current proxyMode, customNoProxy, the test target, and the
|
|
535
|
+
// actual NO_PROXY env var value so the client can show the real state.
|
|
536
|
+
wsCtx.effect(() => wsCtx.webServer.register({
|
|
537
|
+
kind: 'exact',
|
|
538
|
+
path: '/plugins/dsh-flash-proxy/proxy-status',
|
|
539
|
+
handler: async (req, res) => {
|
|
540
|
+
if (req.method !== 'GET') {
|
|
541
|
+
res.statusCode = 405;
|
|
542
|
+
res.setHeader('allow', 'GET');
|
|
543
|
+
res.end();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const mode = resolveMode();
|
|
547
|
+
const custom = config.customNoProxy.get() || '';
|
|
548
|
+
const testUrl = resolveTestUrl();
|
|
549
|
+
const route = await proxyRouteForUrl(testUrl);
|
|
550
|
+
sendJson(res, 200, {
|
|
551
|
+
// Global master state. When false, resolveMode() forces 'all-bypass'
|
|
552
|
+
// regardless of proxyMode — the client uses this flag to render the
|
|
553
|
+
// master toggle and to distinguish "proxy disabled" from "mode =
|
|
554
|
+
// all-bypass" chosen explicitly.
|
|
555
|
+
proxyEnabled: config.proxyEnabled?.get() !== false,
|
|
556
|
+
proxyMode: mode,
|
|
557
|
+
customNoProxy: custom,
|
|
558
|
+
testUrl,
|
|
559
|
+
// What this plugin published for the current mode (null = cleared).
|
|
560
|
+
noProxy: resolveNoProxy(mode, custom) ?? null,
|
|
561
|
+
testDefault: DEFAULT_TEST_URL,
|
|
562
|
+
// Probed against the configured test target, not a hardcoded host —
|
|
563
|
+
// "is a proxy active" and "did the test use one" must not disagree.
|
|
564
|
+
proxyAvailable: route.proxied,
|
|
565
|
+
// Whether any proxy variable exists at all. proxyAvailable alone
|
|
566
|
+
// cannot distinguish "no proxy configured" from "configured, and this
|
|
567
|
+
// URL is deliberately bypassed" — the client needs both to avoid
|
|
568
|
+
// telling the user their proxy has no effect when they asked for a
|
|
569
|
+
// bypass.
|
|
570
|
+
httpProxy: readLaunchEnv(['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']),
|
|
571
|
+
// Per-class proxy variables, each verbatim — lets the client render a
|
|
572
|
+
// complete read-only inventory (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY).
|
|
573
|
+
proxyEnv: proxyEnvSummary(),
|
|
574
|
+
routeError: route.error,
|
|
575
|
+
});
|
|
576
|
+
},
|
|
577
|
+
}), 'dsh-flash-proxy: GET /proxy-status');
|
|
578
|
+
// POST /plugins/dsh-flash-proxy/test-connection
|
|
579
|
+
// Connection test with diagnostics — walks the redirect chain manually,
|
|
580
|
+
// measures headers vs body separately, and reports the proxy route decision
|
|
581
|
+
// plus the underlying socket error code.
|
|
582
|
+
wsCtx.effect(() => wsCtx.webServer.register({
|
|
583
|
+
kind: 'exact',
|
|
584
|
+
path: '/plugins/dsh-flash-proxy/test-connection',
|
|
585
|
+
handler: async (req, res) => {
|
|
586
|
+
if (req.method !== 'POST') {
|
|
587
|
+
res.statusCode = 405;
|
|
588
|
+
res.setHeader('allow', 'POST');
|
|
589
|
+
res.end();
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
// Optional { url } override; falls back to the stored setting.
|
|
593
|
+
const body = await readJsonBody(req);
|
|
594
|
+
const testUrl = resolveTestUrl(body && body.url);
|
|
595
|
+
let target;
|
|
596
|
+
try {
|
|
597
|
+
target = new URL(testUrl);
|
|
598
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
|
599
|
+
throw new Error('unsupported protocol: ' + target.protocol);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
catch (e) {
|
|
603
|
+
sendJson(res, 200, {
|
|
604
|
+
ok: false,
|
|
605
|
+
url: testUrl,
|
|
606
|
+
finalUrl: testUrl,
|
|
607
|
+
elapsedMs: 0,
|
|
608
|
+
headersMs: 0,
|
|
609
|
+
bodyMs: 0,
|
|
610
|
+
bodyBytes: 0,
|
|
611
|
+
bodySnippet: null,
|
|
612
|
+
status: 0,
|
|
613
|
+
statusText: '',
|
|
614
|
+
contentType: '',
|
|
615
|
+
contentLength: null,
|
|
616
|
+
redirects: [],
|
|
617
|
+
redirectLimitHit: false,
|
|
618
|
+
timeoutMs: TEST_TIMEOUT_MS,
|
|
619
|
+
proxy: await describeProxyRoute(testUrl, false),
|
|
620
|
+
error: {
|
|
621
|
+
name: 'InvalidTestUrl',
|
|
622
|
+
message: e?.message || String(e),
|
|
623
|
+
code: null,
|
|
624
|
+
causeName: null,
|
|
625
|
+
causeMessage: null,
|
|
626
|
+
causeCode: null,
|
|
627
|
+
causeErrno: null,
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
sendJson(res, 200, await runConnectionTest(testUrl));
|
|
634
|
+
}
|
|
635
|
+
catch (e) {
|
|
636
|
+
// runConnectionTest already reports failures as data; this is a
|
|
637
|
+
// belt-and-braces guard so the route can never 500.
|
|
638
|
+
sendJson(res, 200, {
|
|
639
|
+
ok: false,
|
|
640
|
+
url: testUrl,
|
|
641
|
+
finalUrl: testUrl,
|
|
642
|
+
elapsedMs: 0,
|
|
643
|
+
redirects: [],
|
|
644
|
+
proxy: await describeProxyRoute(testUrl),
|
|
645
|
+
error: { name: 'InternalError', message: e?.message || String(e) },
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
}), 'dsh-flash-proxy: POST /test-connection');
|
|
650
|
+
});
|
|
651
|
+
// ── Migration: adopt proxy settings from dock-flash namespace ───────
|
|
652
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
653
|
+
const settings = settingsCtx.settings;
|
|
654
|
+
if (!settings || typeof settings.describe !== 'function')
|
|
655
|
+
return;
|
|
656
|
+
// `settings` here is the HOST service — `SettingsForms` from
|
|
657
|
+
// @deepseek-ai/dsh-settings — and ITS `describe()` is SYNCHRONOUS: it returns
|
|
658
|
+
// the descriptor ARRAY directly, with no `{ok, value}` envelope. Writing
|
|
659
|
+
// `settings.describe().then(...)` therefore threw
|
|
660
|
+
// `TypeError: settings.describe(...).then is not a function` on the spot, and
|
|
661
|
+
// because the throw happens at the `.then` ACCESS it also escaped the
|
|
662
|
+
// `.catch(() => {})` at the end of the chain — so this migration has never run
|
|
663
|
+
// once. Normalize the result instead, and accept BOTH shapes: the host's bare
|
|
664
|
+
// array, and the remote namespace's `{ ok, value: { namespaces } }`.
|
|
665
|
+
const described = (() => {
|
|
666
|
+
try {
|
|
667
|
+
return Promise.resolve(settings.describe());
|
|
668
|
+
}
|
|
669
|
+
catch (e) {
|
|
670
|
+
return Promise.reject(e);
|
|
671
|
+
}
|
|
672
|
+
})();
|
|
673
|
+
described.then(async (desc) => {
|
|
674
|
+
if (!desc || desc.ok === false)
|
|
675
|
+
return;
|
|
676
|
+
const view = desc.value || desc;
|
|
677
|
+
const nsList = view && Array.isArray(view.namespaces)
|
|
678
|
+
? view.namespaces
|
|
679
|
+
: (Array.isArray(view) ? view : []);
|
|
680
|
+
const oldNs = nsList.find((n) => (n && (n.ns || n.namespace)) === 'dock-flash');
|
|
681
|
+
if (!oldNs)
|
|
682
|
+
return;
|
|
683
|
+
const resolved = oldNs.value || oldNs.resolved;
|
|
684
|
+
if (!resolved)
|
|
685
|
+
return;
|
|
686
|
+
const currentMode = config.proxyMode.get();
|
|
687
|
+
const alreadyMigrated = currentMode && currentMode !== DEFAULT_MODE;
|
|
688
|
+
if (!alreadyMigrated) {
|
|
689
|
+
const patch = {};
|
|
690
|
+
if (resolved.proxyMode && resolved.proxyMode !== DEFAULT_MODE) {
|
|
691
|
+
patch.proxyMode = resolved.proxyMode;
|
|
692
|
+
}
|
|
693
|
+
if (resolved.customNoProxy) {
|
|
694
|
+
patch.customNoProxy = resolved.customNoProxy;
|
|
695
|
+
}
|
|
696
|
+
if (resolved.testUrl && resolved.testUrl !== DEFAULT_TEST_URL) {
|
|
697
|
+
patch.testUrl = resolved.testUrl;
|
|
698
|
+
}
|
|
699
|
+
if (typeof resolved.useProxy === 'boolean' && !resolved.proxyMode) {
|
|
700
|
+
patch.proxyMode = resolved.useProxy ? 'all-proxy' : 'all-bypass';
|
|
701
|
+
}
|
|
702
|
+
if (Object.keys(patch).length > 0) {
|
|
703
|
+
console.log('[dsh-flash-proxy] migrating proxy settings from dock-flash namespace:', patch);
|
|
704
|
+
try {
|
|
705
|
+
await settings.update('dsh-flash-proxy', patch);
|
|
706
|
+
}
|
|
707
|
+
catch (e) {
|
|
708
|
+
console.warn('[dsh-flash-proxy] migration write failed:', e?.message || e);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}).catch(() => { });
|
|
713
|
+
});
|
|
714
|
+
}
|