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.
Files changed (132) hide show
  1. package/README.md +16 -7
  2. package/README.zh.md +16 -7
  3. package/docs/architecture/2.3.0-compat-retirement.md +147 -0
  4. package/docs/architecture/2.3.0-execution-plan.md +410 -0
  5. package/docs/architecture/2.3.0-final-audit.md +279 -0
  6. package/docs/architecture/2.3.0-reopened-plan.md +720 -0
  7. package/docs/architecture/3.0-architecture-plan.md +252 -0
  8. package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
  9. package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
  10. package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
  11. package/docs/architecture/3.0-r11-adversarial-review.md +441 -0
  12. package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
  13. package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
  14. package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
  15. package/docs/architecture/3.0-r7-public-api.md +151 -0
  16. package/docs/architecture/3.0-r8-ci-release.md +248 -0
  17. package/docs/architecture/3.0-r9-final-audit.md +370 -0
  18. package/docs/architecture/compat-inventory.md +26 -34
  19. package/docs/architecture/dsh-compatibility-matrix.md +65 -54
  20. package/docs/architecture/dsh-support-window.md +30 -22
  21. package/docs/architecture/final-closure-audit.md +2 -0
  22. package/docs/releases/v2.3.0.md +53 -0
  23. package/docs/releases/v3.0.0.md +85 -0
  24. package/entry.js +9 -57
  25. package/index.js +268 -761
  26. package/lib/agent-request-route-authority.js +1 -20
  27. package/lib/catalog-corrections.js +1 -2
  28. package/lib/client-maintenance-actions.js +145 -0
  29. package/lib/client-presentation-boundary-main.js +75 -17
  30. package/lib/client-presentation-boundary.js +53 -28
  31. package/lib/client.js +246 -144
  32. package/lib/core-apply-lifecycle.js +41 -0
  33. package/lib/core-cache.js +87 -0
  34. package/lib/core-primitives.js +91 -278
  35. package/lib/core-vision-surface-domain.js +63 -0
  36. package/lib/core-vision-surface.js +11 -77
  37. package/lib/delegated-call-config.js +15 -24
  38. package/lib/desktop-screenshot-tool.js +262 -0
  39. package/lib/doctor-cli.js +0 -0
  40. package/lib/doctor-runtime.js +4 -1
  41. package/lib/dsh-contract-compat.js +37 -256
  42. package/lib/dsh-host-capabilities.js +3 -9
  43. package/lib/dsh-settings-017-compat.js +17 -10
  44. package/lib/dsh-support-window.js +3 -3
  45. package/lib/file-logger.js +8 -3
  46. package/lib/grounding-coordinate-runtime.js +1 -2
  47. package/lib/http-body-limit.js +97 -79
  48. package/lib/http-compat.js +1 -2
  49. package/lib/image-offload-compat.js +30 -21
  50. package/lib/image-resource-governor.js +210 -228
  51. package/lib/legacy-global-proxy-boundary.js +46 -3
  52. package/lib/live-model-client-prelude.js +12 -4
  53. package/lib/live-model-discovery.js +134 -43
  54. package/lib/local-remote-settings-permission.js +33 -7
  55. package/lib/local-vision-stabilizer.js +31 -17
  56. package/lib/maintenance-routes.js +134 -0
  57. package/lib/native-image-coexistence.js +15 -3
  58. package/lib/ollama-cold-start.js +87 -23
  59. package/lib/pi-ai-bridge-wire-compat.js +3 -2
  60. package/lib/proxy-dispatcher-pool.js +151 -92
  61. package/lib/proxy-routing.js +75 -51
  62. package/lib/proxy-url-compat.js +3 -10
  63. package/lib/public-config.js +36 -0
  64. package/lib/public-entry.d.ts +148 -0
  65. package/lib/public-entry.js +19 -11
  66. package/lib/remote-settings-bridge.js +11 -0
  67. package/lib/remote-settings-risk-client-boundary.js +155 -0
  68. package/lib/remote-settings-risk-confirmation.js +17 -105
  69. package/lib/replay-delegation.js +12 -7
  70. package/lib/replay-envelope-v2-compat.js +12 -7
  71. package/lib/runtime-composition.js +127 -34
  72. package/lib/runtime-i18n-boundary.js +10 -11
  73. package/lib/screenshot-source-boundary.js +2 -2
  74. package/lib/session-affinity-runtime.js +113 -97
  75. package/lib/session-affinity.js +89 -76
  76. package/lib/session-event-history-compat.js +30 -0
  77. package/lib/session-message-source-compat.js +7 -3
  78. package/lib/session-query-readers.js +263 -0
  79. package/lib/session-surface-compat.js +30 -31
  80. package/lib/session-surface-policy-domain.js +85 -0
  81. package/lib/session-surface-policy.js +2 -111
  82. package/lib/session-turn-resolver.js +2 -6
  83. package/lib/session-vision-attachment-index.js +143 -0
  84. package/lib/session-vision-delegation-boundary.js +34 -0
  85. package/lib/session-vision-delegation.js +22 -0
  86. package/lib/session-vision-index.js +179 -116
  87. package/lib/session-vision-mode-authority.js +229 -192
  88. package/lib/session-vision-mode-boundary.js +65 -22
  89. package/lib/session-vision-policy-domain.js +304 -0
  90. package/lib/session-vision-policy.js +104 -0
  91. package/lib/session-vision-state.js +368 -378
  92. package/lib/settings-client-017-compat.js +9 -7
  93. package/lib/settings-client-loader-lifecycle.js +129 -0
  94. package/lib/sharp-runtime.js +0 -7
  95. package/lib/structured-flow-hardening.js +25 -6
  96. package/lib/tesseract-exec-compat.js +9 -1
  97. package/lib/turn-budget-context.js +47 -41
  98. package/lib/update-check.js +11 -2
  99. package/lib/vision-attachment-handle-runtime.js +176 -156
  100. package/lib/vision-backend-runtime-policy.js +19 -2
  101. package/lib/vision-background-benchmark.js +41 -4
  102. package/lib/vision-background-failure-policy.js +41 -41
  103. package/lib/vision-background-stop-store-core.js +217 -0
  104. package/lib/vision-background-stop-store.js +8 -179
  105. package/lib/vision-capability-benchmark-service.js +1 -0
  106. package/lib/vision-capability-benchmark.js +11 -61
  107. package/lib/vision-capability-evidence.js +35 -0
  108. package/lib/vision-capability-fingerprint.js +72 -0
  109. package/lib/vision-capability-identity.js +116 -61
  110. package/lib/vision-capability-router.js +13 -33
  111. package/lib/vision-chunk-assembler.js +95 -0
  112. package/lib/vision-diagnostics-routes.js +246 -0
  113. package/lib/vision-evidence-guidance.js +3 -1
  114. package/lib/vision-execution-order-apply.js +45 -55
  115. package/lib/vision-execution-order.js +19 -19
  116. package/lib/vision-execution-policy.js +12 -7
  117. package/lib/vision-model-registry.js +30 -17
  118. package/lib/vision-provider-transport.js +137 -176
  119. package/lib/vision-routing-authority.js +47 -48
  120. package/lib/vision-routing-product.js +26 -29
  121. package/lib/vision-routing-settings-prelude.js +8 -3
  122. package/lib/vision-runtime-performance-store.js +166 -0
  123. package/lib/vision-runtime-performance.js +13 -142
  124. package/lib/vision-toggle-root-hardening.js +22 -5
  125. package/lib/vision-tool-runtime-boundary.js +104 -31
  126. package/lib/web/dsh-settings-017-root-transport.js +27 -12
  127. package/lib/web/remote-settings-client.js +6 -9
  128. package/lib/web-capability-boundary.js +17 -7
  129. package/lib/wrapper-directory.js +14 -23
  130. package/package.json +56 -20
  131. package/lib/android-attachment-compat.js +0 -244
  132. package/lib/settings-client-rc8-lifecycle.js +0 -397
@@ -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 = (...args) => globalThis.fetch(...args),
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 fetchImpl(psUrl, { method: 'GET', signal: probe.signal })
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 readOllamaJson(response, 'Ollama /api/ps response')
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 readOllamaJson(response, 'Ollama preload response')
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
- controllers.clear()
248
- inFlight.clear()
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
- backgroundWarm('settings-ready')
329
- if (typeof scope.watch === 'function') {
330
- try {
331
- scopeUnwatch = scope.watch(() => backgroundWarm('settings-changed'))
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
- /* lifecycle registration is best effort */
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
- /* cleanup registration is best effort */
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
- // Direct callers without Cordis lifecycle still receive the cleanup below.
345
+ } catch (error) {
346
+ cleanup()
347
+ throw error
347
348
  }
348
349
  return cleanup
349
350
  }
@@ -1,96 +1,155 @@
1
- async function closeDispatcher(dispatcher) {
2
- if (!dispatcher || (typeof dispatcher !== 'object' && typeof dispatcher !== 'function')) return
3
- try {
4
- if (typeof dispatcher.close === 'function') {
5
- await dispatcher.close()
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
- export function createProxyDispatcherPool({ importUndici = () => import('undici'), label = 'vision proxy transport' } = {}) {
15
- let cachedEntry
16
- let disposed = false
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
- const dispatcher = await entry.promise
72
- let released = false
73
- return { dispatcher, getGlobalDispatcher: entry.getGlobalDispatcher, release() {
74
- if (released) return
75
- released = true
76
- entry.refs = Math.max(0, entry.refs - 1)
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
- const dispose = () => {
86
- if (disposePromise) return disposePromise
87
- disposed = true
88
- const current = cachedEntry
89
- cachedEntry = undefined
90
- retireEntry(current)
91
- for (const entry of liveEntries) retireEntry(entry)
92
- disposePromise = Promise.all([...liveEntries].map((entry) => entry.done)).then(() => undefined)
93
- return disposePromise
94
- }
95
- return Object.freeze({ acquire, reconcile, dispose })
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
  }
@@ -1,76 +1,100 @@
1
- import { domainToASCII } from 'node:url'
2
-
1
+ import { URL, domainToASCII } from 'node:url';
3
2
  function objectLike(value) {
4
- return value !== null && (typeof value === 'object' || typeof value === 'function')
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
- const value = config && typeof config === 'object' ? config.proxy : undefined
10
- return typeof value === 'string' && value.trim() !== ''
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
- let host = String(value ?? '').trim()
20
- if (host === '') return ''
21
- if (host.endsWith('.')) host = host.slice(0, -1)
22
- if (host === '') return ''
23
-
24
- // WHATWG URL.hostname keeps IPv6 literals bracketed. domainToASCII does not
25
- // accept a bare IPv6 literal, so preserve the bracketed spelling and only
26
- // case-fold it. Proxy hosts are otherwise DNS names and can be IDNA-folded.
27
- if (host.startsWith('[') && host.endsWith(']')) return host.toLowerCase()
28
- const ascii = domainToASCII(host)
29
- return (ascii || host).toLowerCase()
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
- const host = canonicalProxyHost(hostname)
35
- if (host === '') return false
36
- return (hosts ?? []).some((raw) => {
37
- const candidate = canonicalProxyHost(raw)
38
- return candidate !== '' && (host === candidate || host.endsWith(`.${candidate}`))
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
- return objectLike(value) && typeof value.dispatch === 'function'
44
+ return objectLike(value)
45
+ && typeof value.dispatch === 'function';
44
46
  }
45
-
46
47
  function proxyDispatchOriginUrl(options) {
47
- try {
48
- const origin = options?.origin
49
- if (origin instanceof URL) return origin
50
- if (origin !== undefined && origin !== null) return new URL(String(origin))
51
- } catch {
52
- // Unknown dispatcher shapes never authorize the plugin proxy.
53
- }
54
- return undefined
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
- if (!proxyDispatcherLike(proxyDispatcher) || !proxyDispatcherLike(fallbackDispatcher)) {
64
- throw new TypeError('vision proxy hop selector requires proxy and fallback dispatchers')
65
- }
66
- const hosts = [...proxyHosts]
67
- return Object.freeze({
68
- dispatch(options, handler) {
69
- const url = proxyDispatchOriginUrl(options)
70
- const target = url && proxyHostMatchesAny(url.hostname, hosts)
71
- ? proxyDispatcher
72
- : fallbackDispatcher
73
- return target.dispatch(options, handler)
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
  }
@@ -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
- if (typeof value !== 'string') return value
11
- return value.replace(/^(\s*)socks5h:/i, '$1socks5:')
2
+ if (typeof value !== 'string')
3
+ return value;
4
+ return value.replace(/^(\s*)socks5h:/i, '$1socks5:');
12
5
  }