dsh-plugin-subscriptions 0.5.1 → 0.5.2
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/README.md +6 -0
- package/README.zh.md +6 -0
- package/lib/auth/device-flow.d.ts +0 -9
- package/lib/auth/device-flow.js +2 -1
- package/lib/auth/rpc.d.ts +15 -1
- package/lib/auth/rpc.js +98 -3
- package/lib/client/SubscriptionsSection.d.ts +17 -0
- package/lib/client/SubscriptionsSection.js +170 -2
- package/lib/client/index.js +11 -0
- package/lib/client/locales.d.ts +64 -0
- package/lib/client/locales.js +64 -0
- package/lib/client.js +658 -167
- package/lib/client.js.map +1 -1
- package/lib/http.d.ts +114 -0
- package/lib/http.js +402 -0
- package/lib/index.js +464 -48
- package/lib/providers/antigravity.d.ts +90 -0
- package/lib/providers/antigravity.js +392 -0
- package/lib/providers/claude.js +7 -6
- package/lib/providers/codex.js +6 -5
- package/lib/providers/copilot.d.ts +1 -1
- package/lib/providers/copilot.js +9 -8
- package/lib/providers/grok.js +8 -7
- package/lib/tools/image-generate.js +2 -1
- package/lib/tools/video-generate.js +2 -1
- package/lib/tools/x-search.js +2 -1
- package/lib/translate/antigravity.d.ts +110 -0
- package/lib/translate/antigravity.js +303 -0
- package/package.json +14 -9
package/lib/http.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Stored proxy configuration (the proxy.json shape). */
|
|
2
|
+
export interface ProxyConfig {
|
|
3
|
+
/** Whether outbound subscription requests route through {@link url}. */
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
/** Proxy origin: `http://host:port` or `https://host:port`. */
|
|
6
|
+
url: string;
|
|
7
|
+
/** Optional proxy user for basic auth. */
|
|
8
|
+
username?: string;
|
|
9
|
+
/** Optional proxy password for basic auth; never sent back to the client. */
|
|
10
|
+
password?: string;
|
|
11
|
+
/** Hostnames (exact, suffix, or `*.example.com`) that stay direct. */
|
|
12
|
+
bypass: string[];
|
|
13
|
+
}
|
|
14
|
+
/** The proxy config as served to the client: secrets replaced by a flag. */
|
|
15
|
+
export interface ProxyConfigView {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
url: string;
|
|
18
|
+
username?: string;
|
|
19
|
+
/** Whether a password is stored (the password itself never leaves the host). */
|
|
20
|
+
passwordSet: boolean;
|
|
21
|
+
bypass: string[];
|
|
22
|
+
/** Last load/apply failure, when the stored config is unusable. */
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
/** One `proxySet` payload. */
|
|
26
|
+
export interface ProxyInput {
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
url: string;
|
|
29
|
+
username?: string;
|
|
30
|
+
/** `undefined` keeps the stored password, `null`/`''` clears it. */
|
|
31
|
+
password?: string | null;
|
|
32
|
+
bypass?: string[];
|
|
33
|
+
}
|
|
34
|
+
/** One `proxyTest` result. */
|
|
35
|
+
export interface ProxyTestResult {
|
|
36
|
+
/** Whether the destination answered with an HTTP status. */
|
|
37
|
+
ok: boolean;
|
|
38
|
+
/** Whether the request actually went through the proxy (bypass/direct otherwise). */
|
|
39
|
+
viaProxy: boolean;
|
|
40
|
+
/** Status of the answered request, when one was received. */
|
|
41
|
+
status?: number;
|
|
42
|
+
/** Round-trip latency in milliseconds. */
|
|
43
|
+
latencyMs?: number;
|
|
44
|
+
/** Failure message, when no response was received. */
|
|
45
|
+
error?: string;
|
|
46
|
+
}
|
|
47
|
+
/** A draft proxy for one test probe (never persisted). */
|
|
48
|
+
export interface ProxyDraft {
|
|
49
|
+
url: string;
|
|
50
|
+
username?: string;
|
|
51
|
+
password?: string;
|
|
52
|
+
}
|
|
53
|
+
/** Destination the `proxyTest` endpoint probes when none is given. */
|
|
54
|
+
export declare const DEFAULT_PROXY_TEST_URL = "https://api.x.ai/v1/models";
|
|
55
|
+
/** Probe deadline; a hung proxy must not pin the Settings dialog forever. */
|
|
56
|
+
export declare const DEFAULT_PROXY_TEST_TIMEOUT_MS = 15000;
|
|
57
|
+
/** Absolute path of the proxy config file. */
|
|
58
|
+
export declare function proxyFilePath(): string;
|
|
59
|
+
/**
|
|
60
|
+
* Flatten a fetch failure into a readable message: undici wraps the true
|
|
61
|
+
* cause (`connect ECONNREFUSED ...`) behind a bare "fetch failed", so walk
|
|
62
|
+
* the cause chain and append each distinct layer (up to four, cycle-safe).
|
|
63
|
+
* A hostname resolving to several addresses (e.g. `localhost` → ::1 and
|
|
64
|
+
* 127.0.0.1) fails as an `AggregateError` with an empty message, so its
|
|
65
|
+
* per-address `errors` entries are folded in too.
|
|
66
|
+
*/
|
|
67
|
+
export declare function describeFetchError(error: unknown): string;
|
|
68
|
+
/**
|
|
69
|
+
* Parse and validate a proxy URL. Only HTTP(S) proxies are supported because
|
|
70
|
+
* the undici dispatcher speaks CONNECT over HTTP; socks5 is not supported.
|
|
71
|
+
* @param raw - the URL the user configured.
|
|
72
|
+
* @returns the parsed URL (credentials attached by the caller).
|
|
73
|
+
*/
|
|
74
|
+
export declare function parseProxyUrl(raw: string): URL;
|
|
75
|
+
/**
|
|
76
|
+
* Whether a request hostname bypasses the proxy.
|
|
77
|
+
* @param hostname - the request's hostname.
|
|
78
|
+
* @param entries - configured bypass entries: exact host, plain suffix
|
|
79
|
+
* (`example.com` also matches `api.example.com`), or `*.example.com`.
|
|
80
|
+
*/
|
|
81
|
+
export declare function matchesBypass(hostname: string, entries: readonly string[]): boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Current proxy config as served to the client (secrets omitted).
|
|
84
|
+
* @returns the view; {@link ProxyConfigView.error} carries the last
|
|
85
|
+
* load/apply failure when the stored config is unusable.
|
|
86
|
+
*/
|
|
87
|
+
export declare function proxyGetConfig(): Promise<ProxyConfigView>;
|
|
88
|
+
/**
|
|
89
|
+
* Validate, persist, and apply one proxy config. A `password` of `undefined`
|
|
90
|
+
* keeps the stored value; `null` or `''` clears it.
|
|
91
|
+
* @param input - the client's payload.
|
|
92
|
+
* @returns the resulting view (secrets omitted).
|
|
93
|
+
*/
|
|
94
|
+
export declare function proxySetConfig(input: ProxyInput): Promise<ProxyConfigView>;
|
|
95
|
+
/**
|
|
96
|
+
* The fetch caller all subscription code uses: routes through the configured
|
|
97
|
+
* proxy unless the host bypasses it. Identity-passthrough otherwise.
|
|
98
|
+
*
|
|
99
|
+
* Proxied requests run on undici's own fetch (not the global one) so the
|
|
100
|
+
* ProxyAgent dispatcher always comes from the same undici build the request
|
|
101
|
+
* is issued with — a mismatched dispatcher can be silently ignored by the
|
|
102
|
+
* host's global fetch.
|
|
103
|
+
*/
|
|
104
|
+
export declare function proxiedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
105
|
+
/**
|
|
106
|
+
* Probe a destination through a proxy, answering with the HTTP status or a
|
|
107
|
+
* flattened transport error. The probe uses `draft` when given (the dialog's
|
|
108
|
+
* current inputs, without saving) and the stored config otherwise.
|
|
109
|
+
* @param target - `http(s)` URL to fetch; defaults to {@link DEFAULT_PROXY_TEST_URL}.
|
|
110
|
+
* @param draft - unsaved proxy inputs to test; absent means the stored config.
|
|
111
|
+
* @returns the result; any HTTP status counts as a successful connection,
|
|
112
|
+
* only a transport failure is an error.
|
|
113
|
+
*/
|
|
114
|
+
export declare function proxyTestConnection(target?: string, draft?: ProxyDraft): Promise<ProxyTestResult>;
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy routing for every outbound subscription request. When a proxy is
|
|
3
|
+
* configured, {@link proxiedFetch} attaches an undici {@link ProxyAgent} as the
|
|
4
|
+
* fetch `dispatcher`, so token exchanges, model-API streams, usage lookups,
|
|
5
|
+
* model discovery, and the `x_search` / `image_generate` / `video_generate`
|
|
6
|
+
* tools all leave through the proxy without touching their call sites.
|
|
7
|
+
*
|
|
8
|
+
* The config lives at `~/.dsh/plugins/subscriptions/proxy.json` (mode 0600,
|
|
9
|
+
* it may carry a password), sibling to the auth store. The `proxyGet` /
|
|
10
|
+
* `proxySet` / `proxyTest` RPC endpoints drive it from the web Settings page;
|
|
11
|
+
* a saved config applies immediately to subsequent requests.
|
|
12
|
+
*
|
|
13
|
+
* The OAuth authorize step opens in the user's browser, which uses the
|
|
14
|
+
* browser/system proxy and is outside this module's reach.
|
|
15
|
+
*/
|
|
16
|
+
import { ProxyAgent, fetch as undiciFetch } from 'undici';
|
|
17
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
18
|
+
import { dirname } from 'node:path';
|
|
19
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
20
|
+
/**
|
|
21
|
+
* undici's own fetch, typed to the DOM fetch signature: its bundled types are
|
|
22
|
+
* stricter (Request requires `duplex`, `RequestInit.body` is non-null) and
|
|
23
|
+
* incompatible with the DOM shapes the provider code passes. The runtime
|
|
24
|
+
* object is the same Web-fetch implementation Node uses.
|
|
25
|
+
*/
|
|
26
|
+
const dispatchFetch = undiciFetch;
|
|
27
|
+
/** Destination the `proxyTest` endpoint probes when none is given. */
|
|
28
|
+
export const DEFAULT_PROXY_TEST_URL = 'https://api.x.ai/v1/models';
|
|
29
|
+
/** Probe deadline; a hung proxy must not pin the Settings dialog forever. */
|
|
30
|
+
export const DEFAULT_PROXY_TEST_TIMEOUT_MS = 15_000;
|
|
31
|
+
/** Disabled configuration: the module state before the first load. */
|
|
32
|
+
const DISABLED = { enabled: false, url: '', bypass: [] };
|
|
33
|
+
/** Current config; updated by every load/apply/save. */
|
|
34
|
+
let current = DISABLED;
|
|
35
|
+
/** The live dispatcher, or undefined when proxies are off/errored. */
|
|
36
|
+
let agent;
|
|
37
|
+
/** Last load/apply failure, surfaced by the config view. */
|
|
38
|
+
let configError;
|
|
39
|
+
/** One lazy load of the on-disk config (module-import cheap; file read once). */
|
|
40
|
+
let ready;
|
|
41
|
+
/** Absolute path of the proxy config file. */
|
|
42
|
+
export function proxyFilePath() {
|
|
43
|
+
return dshHomePath('plugins', 'subscriptions', 'proxy.json');
|
|
44
|
+
}
|
|
45
|
+
function errorMessage(error) {
|
|
46
|
+
return error instanceof Error ? error.message : String(error);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Flatten a fetch failure into a readable message: undici wraps the true
|
|
50
|
+
* cause (`connect ECONNREFUSED ...`) behind a bare "fetch failed", so walk
|
|
51
|
+
* the cause chain and append each distinct layer (up to four, cycle-safe).
|
|
52
|
+
* A hostname resolving to several addresses (e.g. `localhost` → ::1 and
|
|
53
|
+
* 127.0.0.1) fails as an `AggregateError` with an empty message, so its
|
|
54
|
+
* per-address `errors` entries are folded in too.
|
|
55
|
+
*/
|
|
56
|
+
export function describeFetchError(error) {
|
|
57
|
+
const parts = [];
|
|
58
|
+
let node = error;
|
|
59
|
+
for (let depth = 0; depth < 4 && node !== undefined && node !== null; depth += 1) {
|
|
60
|
+
const layer = node;
|
|
61
|
+
if (Array.isArray(layer.errors)) {
|
|
62
|
+
for (const child of layer.errors) {
|
|
63
|
+
const childText = child instanceof Error && child.message !== '' ? child.message : String(child);
|
|
64
|
+
if (childText !== '' && !parts.includes(childText))
|
|
65
|
+
parts.push(childText);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
let text = layer instanceof Error ? layer.message : String(node);
|
|
69
|
+
const code = layer.code;
|
|
70
|
+
if (typeof code === 'string' && code !== '') {
|
|
71
|
+
if (text === '')
|
|
72
|
+
text = code;
|
|
73
|
+
else if (!text.includes(code))
|
|
74
|
+
text = `${text} (${code})`;
|
|
75
|
+
}
|
|
76
|
+
if (text !== '' && !parts.includes(text))
|
|
77
|
+
parts.push(text);
|
|
78
|
+
const next = layer.cause;
|
|
79
|
+
if (next === undefined || next === null || next === node)
|
|
80
|
+
break;
|
|
81
|
+
node = next;
|
|
82
|
+
}
|
|
83
|
+
return parts.join(' → ');
|
|
84
|
+
}
|
|
85
|
+
function withError(error) {
|
|
86
|
+
configError = errorMessage(error);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Parse and validate a proxy URL. Only HTTP(S) proxies are supported because
|
|
90
|
+
* the undici dispatcher speaks CONNECT over HTTP; socks5 is not supported.
|
|
91
|
+
* @param raw - the URL the user configured.
|
|
92
|
+
* @returns the parsed URL (credentials attached by the caller).
|
|
93
|
+
*/
|
|
94
|
+
export function parseProxyUrl(raw) {
|
|
95
|
+
let url;
|
|
96
|
+
try {
|
|
97
|
+
url = new URL(raw);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new Error(`proxy URL "${raw}" is not a valid URL`);
|
|
101
|
+
}
|
|
102
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
103
|
+
throw new Error(`proxy URL must use the http:// or https:// scheme (got "${raw}")`);
|
|
104
|
+
}
|
|
105
|
+
if (url.hostname === '')
|
|
106
|
+
throw new Error('proxy URL must include a host');
|
|
107
|
+
return url;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Whether a request hostname bypasses the proxy.
|
|
111
|
+
* @param hostname - the request's hostname.
|
|
112
|
+
* @param entries - configured bypass entries: exact host, plain suffix
|
|
113
|
+
* (`example.com` also matches `api.example.com`), or `*.example.com`.
|
|
114
|
+
*/
|
|
115
|
+
export function matchesBypass(hostname, entries) {
|
|
116
|
+
const host = hostname.toLowerCase();
|
|
117
|
+
for (const raw of entries) {
|
|
118
|
+
let entry = raw.trim().toLowerCase();
|
|
119
|
+
if (entry === '')
|
|
120
|
+
continue;
|
|
121
|
+
if (entry.includes('://')) {
|
|
122
|
+
try {
|
|
123
|
+
entry = new URL(entry).hostname;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
entry = entry.replace(/:\d+$/, '');
|
|
130
|
+
if (entry === '' || entry === '*')
|
|
131
|
+
continue;
|
|
132
|
+
if (entry.startsWith('*.')) {
|
|
133
|
+
if (host.endsWith(entry.slice(1)))
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
else if (host === entry || host.endsWith(`.${entry}`)) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
/** Validate and normalize one config (throws with a user-facing message). */
|
|
143
|
+
function normalizeConfig(input) {
|
|
144
|
+
const url = input.url.trim();
|
|
145
|
+
if (input.enabled && url === '') {
|
|
146
|
+
throw new Error('a proxy URL is required when the proxy is enabled');
|
|
147
|
+
}
|
|
148
|
+
if (url !== '')
|
|
149
|
+
parseProxyUrl(url);
|
|
150
|
+
const bypass = Array.from(new Set((input.bypass ?? [])
|
|
151
|
+
.map(entry => entry.trim())
|
|
152
|
+
.filter(entry => entry !== '')));
|
|
153
|
+
return {
|
|
154
|
+
enabled: input.enabled,
|
|
155
|
+
url,
|
|
156
|
+
...input.username !== undefined && input.username !== '' ? { username: input.username.trim() } : {},
|
|
157
|
+
...input.password !== undefined && input.password !== '' && input.password !== null ? { password: input.password } : {},
|
|
158
|
+
bypass,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** Build the undici agent for a config (throws on an unusable URL). */
|
|
162
|
+
function buildAgent(cfg) {
|
|
163
|
+
if (!cfg.enabled || cfg.url === '')
|
|
164
|
+
return undefined;
|
|
165
|
+
const url = parseProxyUrl(cfg.url);
|
|
166
|
+
if (cfg.username !== undefined)
|
|
167
|
+
url.username = cfg.username;
|
|
168
|
+
if (cfg.password !== undefined)
|
|
169
|
+
url.password = cfg.password;
|
|
170
|
+
return new ProxyAgent(url.toString());
|
|
171
|
+
}
|
|
172
|
+
/** Swap in a config and its agent; a failed agent keeps the requests direct. */
|
|
173
|
+
async function applyConfig(cfg) {
|
|
174
|
+
let next;
|
|
175
|
+
if (cfg !== undefined) {
|
|
176
|
+
configError = undefined;
|
|
177
|
+
try {
|
|
178
|
+
next = buildAgent(cfg);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
withError(error);
|
|
182
|
+
next = undefined;
|
|
183
|
+
}
|
|
184
|
+
current = cfg;
|
|
185
|
+
}
|
|
186
|
+
const previous = agent;
|
|
187
|
+
agent = next;
|
|
188
|
+
if (previous !== undefined)
|
|
189
|
+
void previous.close().catch(() => undefined);
|
|
190
|
+
}
|
|
191
|
+
/** Read the on-disk config. A missing file is the disabled default. */
|
|
192
|
+
async function loadConfigFile(path) {
|
|
193
|
+
let text;
|
|
194
|
+
try {
|
|
195
|
+
text = await readFile(path, 'utf8');
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (error.code === 'ENOENT')
|
|
199
|
+
return { ...DISABLED, bypass: [] };
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
let parsed;
|
|
203
|
+
try {
|
|
204
|
+
parsed = JSON.parse(text);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
throw new Error(`subscriptions proxy config at ${path} is not valid JSON; fix or delete the file`);
|
|
208
|
+
}
|
|
209
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
210
|
+
throw new Error('subscriptions proxy config must be a JSON object');
|
|
211
|
+
}
|
|
212
|
+
const record = parsed;
|
|
213
|
+
const enabled = record.enabled === true;
|
|
214
|
+
const url = typeof record.url === 'string' ? record.url : '';
|
|
215
|
+
const username = typeof record.username === 'string' ? record.username : undefined;
|
|
216
|
+
const password = typeof record.password === 'string' ? record.password : undefined;
|
|
217
|
+
const bypass = Array.isArray(record.bypass)
|
|
218
|
+
? record.bypass.filter((entry) => typeof entry === 'string')
|
|
219
|
+
: [];
|
|
220
|
+
return normalizeConfig({
|
|
221
|
+
enabled,
|
|
222
|
+
url,
|
|
223
|
+
...username === undefined ? {} : { username },
|
|
224
|
+
...password === undefined ? {} : { password },
|
|
225
|
+
bypass,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/** Resolve the module state once from disk; failures disable the proxy. */
|
|
229
|
+
async function ensureReady() {
|
|
230
|
+
ready ??= loadConfigFile(proxyFilePath()).then(async (cfg) => {
|
|
231
|
+
await applyConfig(cfg);
|
|
232
|
+
return current;
|
|
233
|
+
}, async (error) => {
|
|
234
|
+
withError(error);
|
|
235
|
+
await applyConfig(undefined);
|
|
236
|
+
return current;
|
|
237
|
+
});
|
|
238
|
+
return ready;
|
|
239
|
+
}
|
|
240
|
+
/** Persist a config atomically with owner-only permissions, then apply it. */
|
|
241
|
+
async function persistConfig(cfg, path) {
|
|
242
|
+
await mkdir(dirname(path), { recursive: true });
|
|
243
|
+
const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
244
|
+
try {
|
|
245
|
+
await writeFile(tmp, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
246
|
+
await chmod(tmp, 0o600);
|
|
247
|
+
await rename(tmp, path);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
await rm(tmp, { force: true });
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Close the live agent and drop the cached config. Test-only: lets a suite
|
|
256
|
+
* unwind the agent's keep-alive sockets before the process exits.
|
|
257
|
+
* @internal Exported for tests only; not part of the plugin's public surface.
|
|
258
|
+
*/
|
|
259
|
+
export async function resetProxyForTests() {
|
|
260
|
+
const previous = agent;
|
|
261
|
+
agent = undefined;
|
|
262
|
+
current = { ...DISABLED, bypass: [] };
|
|
263
|
+
ready = undefined;
|
|
264
|
+
configError = undefined;
|
|
265
|
+
if (previous !== undefined)
|
|
266
|
+
await previous.close().catch(() => undefined);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Current proxy config as served to the client (secrets omitted).
|
|
270
|
+
* @returns the view; {@link ProxyConfigView.error} carries the last
|
|
271
|
+
* load/apply failure when the stored config is unusable.
|
|
272
|
+
*/
|
|
273
|
+
export async function proxyGetConfig() {
|
|
274
|
+
await ensureReady();
|
|
275
|
+
return {
|
|
276
|
+
enabled: current.enabled,
|
|
277
|
+
url: current.url,
|
|
278
|
+
...current.username === undefined ? {} : { username: current.username },
|
|
279
|
+
passwordSet: current.password !== undefined && current.password !== '',
|
|
280
|
+
bypass: [...current.bypass],
|
|
281
|
+
...configError === undefined ? {} : { error: configError },
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Validate, persist, and apply one proxy config. A `password` of `undefined`
|
|
286
|
+
* keeps the stored value; `null` or `''` clears it.
|
|
287
|
+
* @param input - the client's payload.
|
|
288
|
+
* @returns the resulting view (secrets omitted).
|
|
289
|
+
*/
|
|
290
|
+
export async function proxySetConfig(input) {
|
|
291
|
+
await ensureReady();
|
|
292
|
+
const password = input.password === undefined
|
|
293
|
+
? current.password
|
|
294
|
+
: input.password === null || input.password === ''
|
|
295
|
+
? undefined
|
|
296
|
+
: input.password;
|
|
297
|
+
const next = normalizeConfig({
|
|
298
|
+
enabled: input.enabled,
|
|
299
|
+
url: input.url,
|
|
300
|
+
...input.username === undefined ? {} : { username: input.username },
|
|
301
|
+
...password === undefined ? {} : { password },
|
|
302
|
+
bypass: input.bypass ?? current.bypass,
|
|
303
|
+
});
|
|
304
|
+
await persistConfig(next, proxyFilePath());
|
|
305
|
+
await applyConfig(next);
|
|
306
|
+
return proxyGetConfig();
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The fetch caller all subscription code uses: routes through the configured
|
|
310
|
+
* proxy unless the host bypasses it. Identity-passthrough otherwise.
|
|
311
|
+
*
|
|
312
|
+
* Proxied requests run on undici's own fetch (not the global one) so the
|
|
313
|
+
* ProxyAgent dispatcher always comes from the same undici build the request
|
|
314
|
+
* is issued with — a mismatched dispatcher can be silently ignored by the
|
|
315
|
+
* host's global fetch.
|
|
316
|
+
*/
|
|
317
|
+
export async function proxiedFetch(input, init = {}) {
|
|
318
|
+
await ensureReady();
|
|
319
|
+
let dispatcher;
|
|
320
|
+
if (current.enabled && agent !== undefined) {
|
|
321
|
+
let hostname = '';
|
|
322
|
+
try {
|
|
323
|
+
const url = typeof input === 'string' ? new URL(input) : input instanceof URL ? input : new URL(input.url);
|
|
324
|
+
hostname = url.hostname;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
hostname = '';
|
|
328
|
+
}
|
|
329
|
+
if (!matchesBypass(hostname, current.bypass))
|
|
330
|
+
dispatcher = agent;
|
|
331
|
+
}
|
|
332
|
+
if (dispatcher === undefined)
|
|
333
|
+
return fetch(input, init);
|
|
334
|
+
const proxied = { ...init, dispatcher };
|
|
335
|
+
return dispatchFetch(input, proxied);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Probe a destination through a proxy, answering with the HTTP status or a
|
|
339
|
+
* flattened transport error. The probe uses `draft` when given (the dialog's
|
|
340
|
+
* current inputs, without saving) and the stored config otherwise.
|
|
341
|
+
* @param target - `http(s)` URL to fetch; defaults to {@link DEFAULT_PROXY_TEST_URL}.
|
|
342
|
+
* @param draft - unsaved proxy inputs to test; absent means the stored config.
|
|
343
|
+
* @returns the result; any HTTP status counts as a successful connection,
|
|
344
|
+
* only a transport failure is an error.
|
|
345
|
+
*/
|
|
346
|
+
export async function proxyTestConnection(target = DEFAULT_PROXY_TEST_URL, draft) {
|
|
347
|
+
let parsed;
|
|
348
|
+
try {
|
|
349
|
+
parsed = new URL(target);
|
|
350
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
351
|
+
return { ok: false, viaProxy: false, error: `test destination must be http or https (got "${parsed.protocol}//")` };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
return { ok: false, viaProxy: false, error: errorMessage(error) };
|
|
356
|
+
}
|
|
357
|
+
await ensureReady();
|
|
358
|
+
let probeAgent;
|
|
359
|
+
let viaProxy;
|
|
360
|
+
let closeProbe = false;
|
|
361
|
+
if (draft !== undefined) {
|
|
362
|
+
// Test the typed values: a throw here is a config problem, not a route one.
|
|
363
|
+
try {
|
|
364
|
+
probeAgent = buildAgent(normalizeConfig({
|
|
365
|
+
enabled: true,
|
|
366
|
+
url: draft.url,
|
|
367
|
+
...draft.username === undefined || draft.username === '' ? {} : { username: draft.username },
|
|
368
|
+
...draft.password === undefined || draft.password === '' ? {} : { password: draft.password },
|
|
369
|
+
bypass: [],
|
|
370
|
+
}));
|
|
371
|
+
viaProxy = probeAgent !== undefined;
|
|
372
|
+
closeProbe = true;
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
return { ok: false, viaProxy: false, error: errorMessage(error) };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
viaProxy = current.enabled && agent !== undefined && !matchesBypass(parsed.hostname, current.bypass);
|
|
380
|
+
probeAgent = viaProxy ? agent : undefined;
|
|
381
|
+
}
|
|
382
|
+
const started = Date.now();
|
|
383
|
+
try {
|
|
384
|
+
const init = probeAgent !== undefined
|
|
385
|
+
? { method: 'GET', dispatcher: probeAgent, signal: AbortSignal.timeout(DEFAULT_PROXY_TEST_TIMEOUT_MS) }
|
|
386
|
+
: { method: 'GET', signal: AbortSignal.timeout(DEFAULT_PROXY_TEST_TIMEOUT_MS) };
|
|
387
|
+
const response = probeAgent !== undefined
|
|
388
|
+
? await dispatchFetch(parsed.toString(), init)
|
|
389
|
+
: await fetch(parsed.toString(), init);
|
|
390
|
+
// Drain so the connection can be released; the body is irrelevant.
|
|
391
|
+
void response.arrayBuffer().catch(() => undefined);
|
|
392
|
+
return { ok: true, viaProxy, status: response.status, latencyMs: Date.now() - started };
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
return { ok: false, viaProxy, latencyMs: Date.now() - started, error: describeFetchError(error) };
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
if (closeProbe && probeAgent !== undefined) {
|
|
399
|
+
await probeAgent.close().catch(() => undefined);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|