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/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
+ }