dsh-vision-router 2.2.8 → 3.0.0
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 +16 -7
- package/README.zh.md +16 -7
- package/docs/architecture/2.3.0-compat-retirement.md +147 -0
- package/docs/architecture/2.3.0-execution-plan.md +410 -0
- package/docs/architecture/2.3.0-final-audit.md +279 -0
- package/docs/architecture/2.3.0-reopened-plan.md +720 -0
- package/docs/architecture/3.0-architecture-plan.md +252 -0
- package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
- package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
- package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
- package/docs/architecture/3.0-r11-adversarial-review.md +441 -0
- package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
- package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
- package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
- package/docs/architecture/3.0-r7-public-api.md +151 -0
- package/docs/architecture/3.0-r8-ci-release.md +248 -0
- package/docs/architecture/3.0-r9-final-audit.md +370 -0
- package/docs/architecture/compat-inventory.md +26 -34
- package/docs/architecture/dsh-compatibility-matrix.md +65 -54
- package/docs/architecture/dsh-support-window.md +30 -22
- package/docs/architecture/final-closure-audit.md +2 -0
- package/docs/releases/v2.3.0.md +53 -0
- package/docs/releases/v3.0.0.md +85 -0
- package/entry.js +9 -57
- package/index.js +268 -761
- package/lib/agent-request-route-authority.js +1 -20
- package/lib/catalog-corrections.js +1 -2
- package/lib/client-maintenance-actions.js +145 -0
- package/lib/client-presentation-boundary-main.js +75 -17
- package/lib/client-presentation-boundary.js +53 -28
- package/lib/client.js +246 -144
- package/lib/core-apply-lifecycle.js +41 -0
- package/lib/core-cache.js +87 -0
- package/lib/core-primitives.js +91 -278
- package/lib/core-vision-surface-domain.js +63 -0
- package/lib/core-vision-surface.js +11 -77
- package/lib/delegated-call-config.js +15 -24
- package/lib/desktop-screenshot-tool.js +262 -0
- package/lib/doctor-cli.js +0 -0
- package/lib/doctor-runtime.js +4 -1
- package/lib/dsh-contract-compat.js +37 -256
- package/lib/dsh-host-capabilities.js +3 -9
- package/lib/dsh-settings-017-compat.js +17 -10
- package/lib/dsh-support-window.js +3 -3
- package/lib/file-logger.js +8 -3
- package/lib/grounding-coordinate-runtime.js +1 -2
- package/lib/http-body-limit.js +97 -79
- package/lib/http-compat.js +1 -2
- package/lib/image-offload-compat.js +30 -21
- package/lib/image-resource-governor.js +210 -228
- package/lib/legacy-global-proxy-boundary.js +46 -3
- package/lib/live-model-client-prelude.js +12 -4
- package/lib/live-model-discovery.js +134 -43
- package/lib/local-remote-settings-permission.js +33 -7
- package/lib/local-vision-stabilizer.js +31 -17
- package/lib/maintenance-routes.js +134 -0
- package/lib/native-image-coexistence.js +15 -3
- package/lib/ollama-cold-start.js +87 -23
- package/lib/pi-ai-bridge-wire-compat.js +3 -2
- package/lib/proxy-dispatcher-pool.js +151 -92
- package/lib/proxy-routing.js +75 -51
- package/lib/proxy-url-compat.js +3 -10
- package/lib/public-config.js +36 -0
- package/lib/public-entry.d.ts +148 -0
- package/lib/public-entry.js +19 -11
- package/lib/remote-settings-bridge.js +11 -0
- package/lib/remote-settings-risk-client-boundary.js +155 -0
- package/lib/remote-settings-risk-confirmation.js +17 -105
- package/lib/replay-delegation.js +12 -7
- package/lib/replay-envelope-v2-compat.js +12 -7
- package/lib/runtime-composition.js +127 -34
- package/lib/runtime-i18n-boundary.js +10 -11
- package/lib/screenshot-source-boundary.js +2 -2
- package/lib/session-affinity-runtime.js +113 -97
- package/lib/session-affinity.js +89 -76
- package/lib/session-event-history-compat.js +30 -0
- package/lib/session-message-source-compat.js +7 -3
- package/lib/session-query-readers.js +263 -0
- package/lib/session-surface-compat.js +30 -31
- package/lib/session-surface-policy-domain.js +85 -0
- package/lib/session-surface-policy.js +2 -111
- package/lib/session-turn-resolver.js +2 -6
- package/lib/session-vision-attachment-index.js +143 -0
- package/lib/session-vision-delegation-boundary.js +34 -0
- package/lib/session-vision-delegation.js +22 -0
- package/lib/session-vision-index.js +179 -116
- package/lib/session-vision-mode-authority.js +229 -192
- package/lib/session-vision-mode-boundary.js +65 -22
- package/lib/session-vision-policy-domain.js +304 -0
- package/lib/session-vision-policy.js +104 -0
- package/lib/session-vision-state.js +368 -378
- package/lib/settings-client-017-compat.js +9 -7
- package/lib/settings-client-loader-lifecycle.js +129 -0
- package/lib/sharp-runtime.js +0 -7
- package/lib/structured-flow-hardening.js +25 -6
- package/lib/tesseract-exec-compat.js +9 -1
- package/lib/turn-budget-context.js +47 -41
- package/lib/update-check.js +11 -2
- package/lib/vision-attachment-handle-runtime.js +176 -156
- package/lib/vision-backend-runtime-policy.js +19 -2
- package/lib/vision-background-benchmark.js +41 -4
- package/lib/vision-background-failure-policy.js +41 -41
- package/lib/vision-background-stop-store-core.js +217 -0
- package/lib/vision-background-stop-store.js +8 -179
- package/lib/vision-capability-benchmark-service.js +1 -0
- package/lib/vision-capability-benchmark.js +11 -61
- package/lib/vision-capability-evidence.js +35 -0
- package/lib/vision-capability-fingerprint.js +72 -0
- package/lib/vision-capability-identity.js +116 -61
- package/lib/vision-capability-router.js +13 -33
- package/lib/vision-chunk-assembler.js +95 -0
- package/lib/vision-diagnostics-routes.js +246 -0
- package/lib/vision-evidence-guidance.js +3 -1
- package/lib/vision-execution-order-apply.js +45 -55
- package/lib/vision-execution-order.js +19 -19
- package/lib/vision-execution-policy.js +12 -7
- package/lib/vision-model-registry.js +30 -17
- package/lib/vision-provider-transport.js +137 -176
- package/lib/vision-routing-authority.js +47 -48
- package/lib/vision-routing-product.js +26 -29
- package/lib/vision-routing-settings-prelude.js +8 -3
- package/lib/vision-runtime-performance-store.js +166 -0
- package/lib/vision-runtime-performance.js +13 -142
- package/lib/vision-toggle-root-hardening.js +22 -5
- package/lib/vision-tool-runtime-boundary.js +104 -31
- package/lib/web/dsh-settings-017-root-transport.js +27 -12
- package/lib/web/remote-settings-client.js +6 -9
- package/lib/web-capability-boundary.js +17 -7
- package/lib/wrapper-directory.js +14 -23
- package/package.json +56 -20
- package/lib/android-attachment-compat.js +0 -244
- package/lib/settings-client-rc8-lifecycle.js +0 -397
package/lib/ollama-cold-start.js
CHANGED
|
@@ -10,6 +10,17 @@ export const OLLAMA_WARMUP_KEEP_ALIVE = '30m'
|
|
|
10
10
|
export const OLLAMA_WARMUP_TIMEOUT_MS = 120000
|
|
11
11
|
export const OLLAMA_PROBE_TIMEOUT_MS = 1500
|
|
12
12
|
|
|
13
|
+
// Capture the process fetch once, at module evaluation, exactly like the
|
|
14
|
+
// Router-owned provider transport. A per-call `(...args) =>
|
|
15
|
+
// globalThis.fetch(...args)` default would re-admit DVR's own later-installed
|
|
16
|
+
// process-wide fetch wrappers (legacy proxy boundary, i18n/pi-ai bridge) into
|
|
17
|
+
// Ollama warmup traffic, so the transport a warmup uses would depend on when
|
|
18
|
+
// the warmup happens to run. Probe-only traffic must stay on the same frozen
|
|
19
|
+
// transport as Router-owned provider calls.
|
|
20
|
+
const moduleFetch = typeof globalThis.fetch === 'function'
|
|
21
|
+
? globalThis.fetch.bind(globalThis)
|
|
22
|
+
: undefined
|
|
23
|
+
|
|
13
24
|
function errorText(error) {
|
|
14
25
|
return error && error.message ? error.message : String(error)
|
|
15
26
|
}
|
|
@@ -71,6 +82,32 @@ function timeoutSignal(ms, controllers) {
|
|
|
71
82
|
}
|
|
72
83
|
}
|
|
73
84
|
|
|
85
|
+
function abortReason(signal) {
|
|
86
|
+
return signal?.reason instanceof Error
|
|
87
|
+
? signal.reason
|
|
88
|
+
: new Error('Ollama warmup aborted')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function awaitAbortable(task, signal) {
|
|
92
|
+
if (!signal || typeof signal.addEventListener !== 'function') return Promise.resolve(task)
|
|
93
|
+
if (signal.aborted) return Promise.reject(abortReason(signal))
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
let settled = false
|
|
96
|
+
const finish = (callback, value) => {
|
|
97
|
+
if (settled) return
|
|
98
|
+
settled = true
|
|
99
|
+
signal.removeEventListener('abort', onAbort)
|
|
100
|
+
callback(value)
|
|
101
|
+
}
|
|
102
|
+
const onAbort = () => finish(reject, abortReason(signal))
|
|
103
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
104
|
+
Promise.resolve(task).then(
|
|
105
|
+
(value) => finish(resolve, value),
|
|
106
|
+
(error) => finish(reject, error),
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
74
111
|
async function cancelBody(response) {
|
|
75
112
|
try {
|
|
76
113
|
await response?.body?.cancel?.()
|
|
@@ -112,7 +149,7 @@ async function readOllamaJson(response, label) {
|
|
|
112
149
|
* that the model finished loading.
|
|
113
150
|
*/
|
|
114
151
|
export function createOllamaWarmupManager({
|
|
115
|
-
fetchImpl =
|
|
152
|
+
fetchImpl = moduleFetch,
|
|
116
153
|
logger,
|
|
117
154
|
probeTimeoutMs = OLLAMA_PROBE_TIMEOUT_MS,
|
|
118
155
|
warmupTimeoutMs = OLLAMA_WARMUP_TIMEOUT_MS,
|
|
@@ -121,6 +158,7 @@ export function createOllamaWarmupManager({
|
|
|
121
158
|
const inFlight = new Map()
|
|
122
159
|
const controllers = new Set()
|
|
123
160
|
let disposed = false
|
|
161
|
+
let disposePromise
|
|
124
162
|
|
|
125
163
|
const run = async (provider, reason, forceKeepAlive) => {
|
|
126
164
|
const key = providerKey(provider)
|
|
@@ -136,12 +174,18 @@ export function createOllamaWarmupManager({
|
|
|
136
174
|
|
|
137
175
|
const probe = timeoutSignal(probeTimeoutMs, controllers)
|
|
138
176
|
try {
|
|
139
|
-
const response = await
|
|
177
|
+
const response = await awaitAbortable(
|
|
178
|
+
fetchImpl(psUrl, { method: 'GET', signal: probe.signal }),
|
|
179
|
+
probe.signal,
|
|
180
|
+
)
|
|
140
181
|
if (!response?.ok) {
|
|
141
|
-
await cancelBody(response)
|
|
182
|
+
await awaitAbortable(cancelBody(response), probe.signal)
|
|
142
183
|
return { ok: false, reason: `probe-http-${response?.status ?? 'unknown'}` }
|
|
143
184
|
}
|
|
144
|
-
const payload = await
|
|
185
|
+
const payload = await awaitAbortable(
|
|
186
|
+
readOllamaJson(response, 'Ollama /api/ps response'),
|
|
187
|
+
probe.signal,
|
|
188
|
+
)
|
|
145
189
|
loaded = psContainsModel(payload, provider.model)
|
|
146
190
|
if (loaded && forceKeepAlive !== true) {
|
|
147
191
|
return {
|
|
@@ -159,7 +203,7 @@ export function createOllamaWarmupManager({
|
|
|
159
203
|
|
|
160
204
|
const warm = timeoutSignal(warmupTimeoutMs, controllers)
|
|
161
205
|
try {
|
|
162
|
-
const response = await fetchImpl(generateUrl, {
|
|
206
|
+
const response = await awaitAbortable(fetchImpl(generateUrl, {
|
|
163
207
|
method: 'POST',
|
|
164
208
|
headers: { 'content-type': 'application/json' },
|
|
165
209
|
body: JSON.stringify({
|
|
@@ -169,16 +213,19 @@ export function createOllamaWarmupManager({
|
|
|
169
213
|
keep_alive: keepAlive,
|
|
170
214
|
}),
|
|
171
215
|
signal: warm.signal,
|
|
172
|
-
})
|
|
216
|
+
}), warm.signal)
|
|
173
217
|
if (!response?.ok) {
|
|
174
|
-
await cancelBody(response)
|
|
218
|
+
await awaitAbortable(cancelBody(response), warm.signal)
|
|
175
219
|
return { ok: false, reason: `warmup-http-${response?.status ?? 'unknown'}` }
|
|
176
220
|
}
|
|
177
221
|
// Do not cancel a successful preload body. A real HTTP fetch resolves as
|
|
178
222
|
// soon as headers are available, so draining the bounded JSON response is
|
|
179
223
|
// what proves Ollama finished loading the model before the inference
|
|
180
224
|
// deadline is allowed to start.
|
|
181
|
-
await
|
|
225
|
+
await awaitAbortable(
|
|
226
|
+
readOllamaJson(response, 'Ollama preload response'),
|
|
227
|
+
warm.signal,
|
|
228
|
+
)
|
|
182
229
|
const durationMs = Date.now() - startedAt
|
|
183
230
|
if (durationMs >= 1000) {
|
|
184
231
|
try {
|
|
@@ -240,12 +287,17 @@ export function createOllamaWarmupManager({
|
|
|
240
287
|
}
|
|
241
288
|
|
|
242
289
|
const dispose = () => {
|
|
290
|
+
if (disposePromise) return disposePromise
|
|
243
291
|
disposed = true
|
|
244
292
|
for (const controller of controllers) {
|
|
245
293
|
try { controller.abort(new Error('Vision Router disposed')) } catch { /* best effort */ }
|
|
246
294
|
}
|
|
247
|
-
|
|
248
|
-
|
|
295
|
+
const admitted = [...inFlight.values()]
|
|
296
|
+
disposePromise = Promise.allSettled(admitted).then(() => {
|
|
297
|
+
controllers.clear()
|
|
298
|
+
inFlight.clear()
|
|
299
|
+
})
|
|
300
|
+
return disposePromise
|
|
249
301
|
}
|
|
250
302
|
|
|
251
303
|
return { ensure, background, dispose }
|
|
@@ -291,7 +343,7 @@ export function installOllamaColdStartGuard(ctx, config = {}, core, options = {}
|
|
|
291
343
|
|
|
292
344
|
let rawScope
|
|
293
345
|
let scopeUnwatch
|
|
294
|
-
const manager = options.manager || createOllamaWarmupManager({ logger: ctx.logger })
|
|
346
|
+
const manager = options.manager || createOllamaWarmupManager({ logger: options.logger ?? ctx.logger })
|
|
295
347
|
const rawInject = typeof ctx.inject === 'function' ? ctx.inject.bind(ctx) : undefined
|
|
296
348
|
const rawOn = typeof ctx.on === 'function' ? ctx.on.bind(ctx) : undefined
|
|
297
349
|
const rawLlm = ctx.llm
|
|
@@ -325,14 +377,10 @@ export function installOllamaColdStartGuard(ctx, config = {}, core, options = {}
|
|
|
325
377
|
try { scopeUnwatch() } catch { /* best effort */ }
|
|
326
378
|
scopeUnwatch = undefined
|
|
327
379
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
} catch {
|
|
333
|
-
scopeUnwatch = undefined
|
|
334
|
-
}
|
|
335
|
-
}
|
|
380
|
+
|
|
381
|
+
// Own this dynamic Settings generation before it can start warmup work or
|
|
382
|
+
// install a watcher. A rejected child fiber must never leave rawScope
|
|
383
|
+
// pointing at a dead Settings service.
|
|
336
384
|
try {
|
|
337
385
|
ownerCtx?.effect?.(
|
|
338
386
|
() => () => {
|
|
@@ -345,7 +393,17 @@ export function installOllamaColdStartGuard(ctx, config = {}, core, options = {}
|
|
|
345
393
|
'vision-router: Ollama warmup settings lifecycle',
|
|
346
394
|
)
|
|
347
395
|
} catch {
|
|
348
|
-
|
|
396
|
+
if (rawScope === scope) rawScope = undefined
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
backgroundWarm('settings-ready')
|
|
401
|
+
if (typeof scope.watch === 'function') {
|
|
402
|
+
try {
|
|
403
|
+
scopeUnwatch = scope.watch(() => backgroundWarm('settings-changed'))
|
|
404
|
+
} catch {
|
|
405
|
+
scopeUnwatch = undefined
|
|
406
|
+
}
|
|
349
407
|
}
|
|
350
408
|
}
|
|
351
409
|
|
|
@@ -455,18 +513,24 @@ export function installOllamaColdStartGuard(ctx, config = {}, core, options = {}
|
|
|
455
513
|
|
|
456
514
|
try {
|
|
457
515
|
ctx.effect?.(
|
|
458
|
-
() => () => {
|
|
516
|
+
() => async () => {
|
|
459
517
|
rawScope = undefined
|
|
460
518
|
if (typeof scopeUnwatch === 'function') {
|
|
461
519
|
try { scopeUnwatch() } catch { /* best effort */ }
|
|
462
520
|
scopeUnwatch = undefined
|
|
463
521
|
}
|
|
464
|
-
manager.dispose()
|
|
522
|
+
await manager.dispose()
|
|
465
523
|
},
|
|
466
524
|
'vision-router: Ollama cold-start guard',
|
|
467
525
|
)
|
|
468
526
|
} catch {
|
|
469
|
-
|
|
527
|
+
rawScope = undefined
|
|
528
|
+
if (typeof scopeUnwatch === 'function') {
|
|
529
|
+
try { scopeUnwatch() } catch { /* best effort */ }
|
|
530
|
+
scopeUnwatch = undefined
|
|
531
|
+
}
|
|
532
|
+
void Promise.resolve(manager.dispose()).catch(() => {})
|
|
533
|
+
return ctx
|
|
470
534
|
}
|
|
471
535
|
|
|
472
536
|
// Composition-level localOllama may already be enabled before Settings
|
|
@@ -342,8 +342,9 @@ export function installPiAiBridgeWireCompat(ctx, logger) {
|
|
|
342
342
|
}
|
|
343
343
|
try {
|
|
344
344
|
ctx?.effect?.(() => cleanup, 'vision-router: pi-ai bridge wire compatibility')
|
|
345
|
-
} catch {
|
|
346
|
-
|
|
345
|
+
} catch (error) {
|
|
346
|
+
cleanup()
|
|
347
|
+
throw error
|
|
347
348
|
}
|
|
348
349
|
return cleanup
|
|
349
350
|
}
|
|
@@ -1,96 +1,155 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
return
|
|
7
|
-
}
|
|
8
|
-
if (typeof dispatcher.destroy === 'function') await dispatcher.destroy()
|
|
9
|
-
} catch {
|
|
10
|
-
// Cleanup must not turn a completed request into an unload failure.
|
|
11
|
-
}
|
|
1
|
+
import { proxyDispatcherLike, } from './proxy-routing.js';
|
|
2
|
+
function objectRecord(value) {
|
|
3
|
+
return value !== null && (typeof value === 'object' || typeof value === 'function')
|
|
4
|
+
? value
|
|
5
|
+
: undefined;
|
|
12
6
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
let disposePromise
|
|
18
|
-
const liveEntries = new Set()
|
|
19
|
-
|
|
20
|
-
const maybeCloseEntry = (entry) => {
|
|
21
|
-
if (!entry.obsolete || entry.refs !== 0 || !entry.dispatcher || entry.closing) return
|
|
22
|
-
entry.closing = Promise.resolve().then(() => closeDispatcher(entry.dispatcher)).finally(() => {
|
|
23
|
-
liveEntries.delete(entry)
|
|
24
|
-
entry.resolveDone()
|
|
25
|
-
})
|
|
26
|
-
}
|
|
27
|
-
const retireEntry = (entry) => {
|
|
28
|
-
if (!entry || entry.obsolete) return
|
|
29
|
-
entry.obsolete = true
|
|
30
|
-
maybeCloseEntry(entry)
|
|
31
|
-
}
|
|
32
|
-
const createEntry = (proxyUrl) => {
|
|
33
|
-
let resolveDone
|
|
34
|
-
const entry = {
|
|
35
|
-
proxyUrl, refs: 0, obsolete: false, dispatcher: undefined, getGlobalDispatcher: undefined, closing: undefined,
|
|
36
|
-
done: new Promise((resolve) => { resolveDone = resolve }), resolveDone: () => resolveDone(), promise: undefined,
|
|
37
|
-
}
|
|
38
|
-
liveEntries.add(entry)
|
|
39
|
-
entry.promise = Promise.resolve().then(() => importUndici()).then(({ ProxyAgent, getGlobalDispatcher }) => {
|
|
40
|
-
if (typeof ProxyAgent !== 'function') throw new Error(`${label}: undici ProxyAgent is unavailable`)
|
|
41
|
-
if (typeof getGlobalDispatcher !== 'function') throw new Error(`${label}: undici getGlobalDispatcher is unavailable`)
|
|
42
|
-
entry.getGlobalDispatcher = getGlobalDispatcher
|
|
43
|
-
entry.dispatcher = new ProxyAgent(proxyUrl)
|
|
44
|
-
maybeCloseEntry(entry)
|
|
45
|
-
return entry.dispatcher
|
|
46
|
-
}).catch((error) => {
|
|
47
|
-
if (cachedEntry === entry) cachedEntry = undefined
|
|
48
|
-
liveEntries.delete(entry)
|
|
49
|
-
entry.resolveDone()
|
|
50
|
-
throw error
|
|
51
|
-
})
|
|
52
|
-
return entry
|
|
53
|
-
}
|
|
54
|
-
const reconcile = (proxyUrl) => {
|
|
55
|
-
if (cachedEntry && cachedEntry.proxyUrl !== proxyUrl) {
|
|
56
|
-
const previous = cachedEntry
|
|
57
|
-
cachedEntry = undefined
|
|
58
|
-
retireEntry(previous)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
const acquire = async (proxyUrl) => {
|
|
62
|
-
if (disposed) throw new Error(`${label}: dispatcher pool is disposed`)
|
|
63
|
-
let entry = cachedEntry
|
|
64
|
-
if (!entry || entry.proxyUrl !== proxyUrl || entry.obsolete) {
|
|
65
|
-
if (entry) retireEntry(entry)
|
|
66
|
-
entry = createEntry(proxyUrl)
|
|
67
|
-
cachedEntry = entry
|
|
68
|
-
}
|
|
69
|
-
entry.refs += 1
|
|
7
|
+
async function closeDispatcher(dispatcher) {
|
|
8
|
+
const value = objectRecord(dispatcher);
|
|
9
|
+
if (value === undefined)
|
|
10
|
+
return;
|
|
70
11
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
maybeCloseEntry(entry)
|
|
78
|
-
} }
|
|
79
|
-
} catch (error) {
|
|
80
|
-
entry.refs = Math.max(0, entry.refs - 1)
|
|
81
|
-
maybeCloseEntry(entry)
|
|
82
|
-
throw error
|
|
12
|
+
if (typeof value.close === 'function') {
|
|
13
|
+
await value.close.call(dispatcher);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (typeof value.destroy === 'function')
|
|
17
|
+
await value.destroy.call(dispatcher);
|
|
83
18
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
cachedEntry
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
19
|
+
catch {
|
|
20
|
+
// Cleanup must not turn a completed request into an unload failure.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function createProxyDispatcherPool({ importUndici = () => import('undici'), label = 'vision proxy transport', } = {}) {
|
|
24
|
+
let cachedEntry;
|
|
25
|
+
let disposed = false;
|
|
26
|
+
let disposePromise;
|
|
27
|
+
const liveEntries = new Set();
|
|
28
|
+
const maybeCloseEntry = (entry) => {
|
|
29
|
+
if (!entry.obsolete || entry.refs !== 0 || !entry.dispatcher || entry.closing)
|
|
30
|
+
return;
|
|
31
|
+
entry.closing = Promise.resolve()
|
|
32
|
+
.then(() => closeDispatcher(entry.dispatcher))
|
|
33
|
+
.finally(() => {
|
|
34
|
+
liveEntries.delete(entry);
|
|
35
|
+
entry.resolveDone();
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
const retireEntry = (entry) => {
|
|
39
|
+
if (!entry || entry.obsolete)
|
|
40
|
+
return;
|
|
41
|
+
entry.obsolete = true;
|
|
42
|
+
maybeCloseEntry(entry);
|
|
43
|
+
};
|
|
44
|
+
const createEntry = (proxyUrl) => {
|
|
45
|
+
let resolveDone;
|
|
46
|
+
const done = new Promise((resolve) => { resolveDone = resolve; });
|
|
47
|
+
const entry = {
|
|
48
|
+
proxyUrl,
|
|
49
|
+
refs: 0,
|
|
50
|
+
obsolete: false,
|
|
51
|
+
dispatcher: undefined,
|
|
52
|
+
getGlobalDispatcher: undefined,
|
|
53
|
+
closing: undefined,
|
|
54
|
+
done,
|
|
55
|
+
resolveDone,
|
|
56
|
+
promise: undefined,
|
|
57
|
+
};
|
|
58
|
+
liveEntries.add(entry);
|
|
59
|
+
entry.promise = Promise.resolve()
|
|
60
|
+
.then(importUndici)
|
|
61
|
+
.then((moduleValue) => {
|
|
62
|
+
const module = objectRecord(moduleValue);
|
|
63
|
+
const ProxyAgent = module?.ProxyAgent;
|
|
64
|
+
const getGlobalDispatcher = module?.getGlobalDispatcher;
|
|
65
|
+
if (typeof ProxyAgent !== 'function') {
|
|
66
|
+
throw new Error(`${label}: undici ProxyAgent is unavailable`);
|
|
67
|
+
}
|
|
68
|
+
if (typeof getGlobalDispatcher !== 'function') {
|
|
69
|
+
throw new Error(`${label}: undici getGlobalDispatcher is unavailable`);
|
|
70
|
+
}
|
|
71
|
+
const Constructor = ProxyAgent;
|
|
72
|
+
const dispatcher = new Constructor(proxyUrl);
|
|
73
|
+
if (!proxyDispatcherLike(dispatcher)) {
|
|
74
|
+
throw new Error(`${label}: undici ProxyAgent has no dispatcher contract`);
|
|
75
|
+
}
|
|
76
|
+
entry.getGlobalDispatcher = () => {
|
|
77
|
+
const globalDispatcher = getGlobalDispatcher.call(moduleValue);
|
|
78
|
+
if (!proxyDispatcherLike(globalDispatcher)) {
|
|
79
|
+
throw new Error(`${label}: undici global dispatcher has no dispatcher contract`);
|
|
80
|
+
}
|
|
81
|
+
return globalDispatcher;
|
|
82
|
+
};
|
|
83
|
+
entry.dispatcher = dispatcher;
|
|
84
|
+
maybeCloseEntry(entry);
|
|
85
|
+
return entry.dispatcher;
|
|
86
|
+
})
|
|
87
|
+
.catch((error) => {
|
|
88
|
+
if (cachedEntry === entry)
|
|
89
|
+
cachedEntry = undefined;
|
|
90
|
+
liveEntries.delete(entry);
|
|
91
|
+
entry.resolveDone();
|
|
92
|
+
throw error;
|
|
93
|
+
});
|
|
94
|
+
return entry;
|
|
95
|
+
};
|
|
96
|
+
const reconcile = (proxyUrl) => {
|
|
97
|
+
if (cachedEntry && cachedEntry.proxyUrl !== proxyUrl) {
|
|
98
|
+
const previous = cachedEntry;
|
|
99
|
+
cachedEntry = undefined;
|
|
100
|
+
retireEntry(previous);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const acquire = async (proxyUrl) => {
|
|
104
|
+
if (disposed)
|
|
105
|
+
throw new Error(`${label}: dispatcher pool is disposed`);
|
|
106
|
+
let entry = cachedEntry;
|
|
107
|
+
if (!entry || entry.proxyUrl !== proxyUrl || entry.obsolete) {
|
|
108
|
+
if (entry)
|
|
109
|
+
retireEntry(entry);
|
|
110
|
+
entry = createEntry(proxyUrl);
|
|
111
|
+
cachedEntry = entry;
|
|
112
|
+
}
|
|
113
|
+
entry.refs += 1;
|
|
114
|
+
try {
|
|
115
|
+
const promise = entry.promise;
|
|
116
|
+
if (!promise)
|
|
117
|
+
throw new Error(`${label}: dispatcher entry failed to initialize`);
|
|
118
|
+
const dispatcher = await promise;
|
|
119
|
+
const getGlobalDispatcher = entry.getGlobalDispatcher;
|
|
120
|
+
if (!getGlobalDispatcher) {
|
|
121
|
+
throw new Error(`${label}: global dispatcher accessor is unavailable`);
|
|
122
|
+
}
|
|
123
|
+
let released = false;
|
|
124
|
+
return {
|
|
125
|
+
dispatcher,
|
|
126
|
+
getGlobalDispatcher,
|
|
127
|
+
release() {
|
|
128
|
+
if (released)
|
|
129
|
+
return;
|
|
130
|
+
released = true;
|
|
131
|
+
entry.refs = Math.max(0, entry.refs - 1);
|
|
132
|
+
maybeCloseEntry(entry);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
entry.refs = Math.max(0, entry.refs - 1);
|
|
138
|
+
maybeCloseEntry(entry);
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const dispose = () => {
|
|
143
|
+
if (disposePromise)
|
|
144
|
+
return disposePromise;
|
|
145
|
+
disposed = true;
|
|
146
|
+
const current = cachedEntry;
|
|
147
|
+
cachedEntry = undefined;
|
|
148
|
+
retireEntry(current);
|
|
149
|
+
for (const entry of liveEntries)
|
|
150
|
+
retireEntry(entry);
|
|
151
|
+
disposePromise = Promise.all([...liveEntries].map((entry) => entry.done)).then(() => undefined);
|
|
152
|
+
return disposePromise;
|
|
153
|
+
};
|
|
154
|
+
return Object.freeze({ acquire, reconcile, dispose });
|
|
96
155
|
}
|
package/lib/proxy-routing.js
CHANGED
|
@@ -1,76 +1,100 @@
|
|
|
1
|
-
import { domainToASCII } from 'node:url'
|
|
2
|
-
|
|
1
|
+
import { URL, domainToASCII } from 'node:url';
|
|
3
2
|
function objectLike(value) {
|
|
4
|
-
|
|
3
|
+
return value !== null && (typeof value === 'object' || typeof value === 'function');
|
|
5
4
|
}
|
|
6
|
-
|
|
7
5
|
/** True only when the user explicitly configured a Vision Router proxy override. */
|
|
8
6
|
export function visionProxyOverrideConfigured(config = {}) {
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
const value = objectLike(config)
|
|
8
|
+
? config.proxy
|
|
9
|
+
: undefined;
|
|
10
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
11
11
|
}
|
|
12
|
-
|
|
13
12
|
/**
|
|
14
13
|
* Canonicalize a configured/request hostname for proxy admission only.
|
|
15
14
|
* Persisted settings stay untouched; this removes DNS presentation differences
|
|
16
15
|
* (case, one trailing dot and Unicode) without broadening the suffix policy.
|
|
17
16
|
*/
|
|
18
17
|
export function canonicalProxyHost(value) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
18
|
+
let host = String(value ?? '').trim();
|
|
19
|
+
if (host === '')
|
|
20
|
+
return '';
|
|
21
|
+
if (host.endsWith('.'))
|
|
22
|
+
host = host.slice(0, -1);
|
|
23
|
+
if (host === '')
|
|
24
|
+
return '';
|
|
25
|
+
// WHATWG URL.hostname keeps IPv6 literals bracketed. domainToASCII does not
|
|
26
|
+
// accept a bare IPv6 literal, so preserve the bracketed spelling and only
|
|
27
|
+
// case-fold it. Proxy hosts are otherwise DNS names and can be IDNA-folded.
|
|
28
|
+
if (host.startsWith('[') && host.endsWith(']'))
|
|
29
|
+
return host.toLowerCase();
|
|
30
|
+
const ascii = domainToASCII(host);
|
|
31
|
+
return (ascii || host).toLowerCase();
|
|
30
32
|
}
|
|
31
|
-
|
|
32
33
|
/** Exact host or subdomain match using one shared proxy-host interpretation. */
|
|
33
|
-
export function proxyHostMatchesAny(hostname, hosts) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
export function proxyHostMatchesAny(hostname, hosts = []) {
|
|
35
|
+
const host = canonicalProxyHost(hostname);
|
|
36
|
+
if (host === '')
|
|
37
|
+
return false;
|
|
38
|
+
return hosts.some((raw) => {
|
|
39
|
+
const candidate = canonicalProxyHost(raw);
|
|
40
|
+
return candidate !== '' && (host === candidate || host.endsWith(`.${candidate}`));
|
|
41
|
+
});
|
|
40
42
|
}
|
|
41
|
-
|
|
42
43
|
export function proxyDispatcherLike(value) {
|
|
43
|
-
|
|
44
|
+
return objectLike(value)
|
|
45
|
+
&& typeof value.dispatch === 'function';
|
|
44
46
|
}
|
|
45
|
-
|
|
46
47
|
function proxyDispatchOriginUrl(options) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
try {
|
|
49
|
+
const origin = objectLike(options)
|
|
50
|
+
? options.origin
|
|
51
|
+
: undefined;
|
|
52
|
+
if (origin instanceof URL)
|
|
53
|
+
return origin;
|
|
54
|
+
if (origin !== undefined && origin !== null)
|
|
55
|
+
return new URL(String(origin));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Unknown dispatcher shapes never authorize the plugin proxy.
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
55
61
|
}
|
|
56
|
-
|
|
57
62
|
/**
|
|
58
63
|
* Preserve Fetch/Undici redirect semantics while keeping proxyHosts an upper
|
|
59
64
|
* bound on every hop after a request has entered the DVR proxy override.
|
|
60
65
|
* The fallback dispatcher is borrowed, never owned or closed by this selector.
|
|
61
66
|
*/
|
|
62
67
|
export function createPerHopProxyDispatcher(proxyDispatcher, fallbackDispatcher, proxyHosts = []) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
if (!proxyDispatcherLike(proxyDispatcher) || !proxyDispatcherLike(fallbackDispatcher)) {
|
|
69
|
+
throw new TypeError('vision proxy hop selector requires proxy and fallback dispatchers');
|
|
70
|
+
}
|
|
71
|
+
const hosts = [...proxyHosts];
|
|
72
|
+
const proxy = proxyDispatcher;
|
|
73
|
+
const fallback = fallbackDispatcher;
|
|
74
|
+
// Fetch types model a full Undici Dispatcher, while fetch dispatch itself
|
|
75
|
+
// consumes the dispatch() seam. A Host dispatcher may expose dispatch as a
|
|
76
|
+
// frozen/non-configurable own property; using that borrowed object directly
|
|
77
|
+
// as a Proxy target would make an overriding get trap violate ECMAScript
|
|
78
|
+
// Proxy invariants. Use a neutral shell with the same prototype instead.
|
|
79
|
+
//
|
|
80
|
+
// Every non-dispatch read still resolves against and binds to the real
|
|
81
|
+
// fallback Dispatcher, so the wrapper does not become a second owner for
|
|
82
|
+
// close/destroy/event state and instanceof-style prototype checks remain
|
|
83
|
+
// compatible with the borrowed dispatcher.
|
|
84
|
+
const shell = Object.create(Object.getPrototypeOf(fallback));
|
|
85
|
+
return new Proxy(shell, {
|
|
86
|
+
get(_target, property) {
|
|
87
|
+
if (property === 'dispatch') {
|
|
88
|
+
return (options, handler) => {
|
|
89
|
+
const url = proxyDispatchOriginUrl(options);
|
|
90
|
+
const selected = url && proxyHostMatchesAny(url.hostname, hosts)
|
|
91
|
+
? proxy
|
|
92
|
+
: fallback;
|
|
93
|
+
return selected.dispatch(options, handler);
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const value = Reflect.get(fallback, property, fallback);
|
|
97
|
+
return typeof value === 'function' ? value.bind(fallback) : value;
|
|
98
|
+
},
|
|
99
|
+
});
|
|
76
100
|
}
|
package/lib/proxy-url-compat.js
CHANGED
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Project the persisted proxy setting into the syntax accepted by Undici.
|
|
3
|
-
*
|
|
4
|
-
* Older Vision Router settings copy explicitly recommended `socks5h://`.
|
|
5
|
-
* Keep that persisted user intent untouched and translate only at the runtime
|
|
6
|
-
* dispatcher boundary. Deliberately do not validate or reinterpret any other
|
|
7
|
-
* proxy scheme here: Undici remains the execution authority.
|
|
8
|
-
*/
|
|
9
1
|
export function effectiveProxyUrlForUndici(value) {
|
|
10
|
-
|
|
11
|
-
|
|
2
|
+
if (typeof value !== 'string')
|
|
3
|
+
return value;
|
|
4
|
+
return value.replace(/^(\s*)socks5h:/i, '$1socks5:');
|
|
12
5
|
}
|