feat(ssh): let a nodejs.org mirror serve the pinned Node on this computer and the host (#30981)

* feat(ssh): let a nodejs.org mirror serve the pinned Node on both machines

* fix(ssh): share one deadline across every host download URL

* fix(ssh): give each host download URL its share of the deadline and cap its size

* refactor(ssh): one tool per host, labelled sources and a portable wget watchdog

* fix(ssh): quote this computer's short download reason in the combined error

* fix(ssh): fetch only validated mirror URLs on the host

* fix(ssh): drop an empty query or fragment from the mirror URL

* fix(ssh): keep a cancel or a local error as it is after the mirror fails
This commit is contained in:
Neil
2026-10-19 20:52:44 -07:00
committed by GitHub
parent 0ee0290cc3
commit 66d37178da
18 changed files with 728 additions and 112 deletions
@@ -0,0 +1,20 @@
import type { GlobalSettings } from '../../shared/global-settings-types'
import { normalizeNodeDistMirrorUrl } from '../../shared/node-dist-mirror'
import { normalizeProxyBypassRules, normalizeProxyUrl } from '../../shared/network-proxy'
/** Network fields as they may be stored: an invalid URL is saved as empty, never as typed. */
export function sanitizeNetworkSettings(
args: Partial<GlobalSettings>,
sanitized: Partial<GlobalSettings>
): void {
if ('httpProxyUrl' in args) {
const proxyUrl = normalizeProxyUrl(args.httpProxyUrl)
sanitized.httpProxyUrl = proxyUrl.ok ? proxyUrl.value : ''
}
if ('httpProxyBypassRules' in args) {
sanitized.httpProxyBypassRules = normalizeProxyBypassRules(args.httpProxyBypassRules)
}
if ('nodeDistMirrorUrl' in args) {
sanitized.nodeDistMirrorUrl = normalizeNodeDistMirrorUrl(args.nodeDistMirrorUrl).value
}
}
+2 -8
View File
@@ -18,7 +18,7 @@ import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetr
import { applyElectronProxySettings } from '../network/proxy-settings'
import { applyBrowserSessionProxies } from '../browser/browser-session-proxy'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import { normalizeProxyBypassRules, normalizeProxyUrl } from '../../shared/network-proxy'
import { sanitizeNetworkSettings } from './network-settings-sanitization'
import { normalizeAppIconId } from '../../shared/app-icon'
import { normalizeUiLanguage } from '../../shared/ui-language'
import { applyAppIcon } from '../app-icon'
@@ -155,13 +155,7 @@ export function registerSettingsHandlers(
args.floatingTerminalCwd
)
}
if ('httpProxyUrl' in args) {
const proxyUrl = normalizeProxyUrl(args.httpProxyUrl)
sanitizedArgs.httpProxyUrl = proxyUrl.ok ? proxyUrl.value : ''
}
if ('httpProxyBypassRules' in args) {
sanitizedArgs.httpProxyBypassRules = normalizeProxyBypassRules(args.httpProxyBypassRules)
}
sanitizeNetworkSettings(args, sanitizedArgs)
if ('appIcon' in args) {
sanitizedArgs.appIcon = normalizeAppIconId(args.appIcon)
}
@@ -13,6 +13,7 @@ import { execCommand } from '../../ssh/remote-exec/remote-exec-command'
import { uploadRelayDirectory } from './remote-install-transfers'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import { pinnedNodeRuntimeAsset } from '../../../shared/node-runtime-pin'
import { DesktopDownloadUnreachableError } from '../../node-runtime/pinned-runtime-materializer'
import {
HOST_NODE_DOWNLOAD_FAILED,
HOST_NODE_DOWNLOAD_OK,
@@ -100,9 +101,30 @@ describe('pinned Node when this computer cannot download it', () => {
expect(message).toContain('net::ERR_CONNECTION_REFUSED')
expect(message).toContain('curl: (7) Failed to connect to nodejs.org')
expect(message).toContain(OFFLINE_PATH)
expect(message).toContain('Settings > Advanced > Network > Node.js download mirror')
expect(message).toContain("NODEJS_ORG_MIRROR in this computer's environment")
expect(uploadRelayDirectory).not.toHaveBeenCalled()
})
it("quotes only this computer's short reason, not its standalone advice", async () => {
freshHost(`${HOST_NODE_DOWNLOAD_FAILED}\nnodejs.org: curl exit 7`)
const desktop = () =>
Promise.reject(
new DesktopDownloadUnreachableError(
"This computer couldn't download Node ... so the host doesn't need internet access",
'https://nodejs.org/dist/x: net::ERR_CONNECTION_REFUSED',
new Error('net::ERR_CONNECTION_REFUSED')
)
)
const message = String(await ensure(desktop).catch((error: unknown) => error))
expect(message).toContain(
'This computer: https://nodejs.org/dist/x: net::ERR_CONNECTION_REFUSED.'
)
expect(message).not.toContain("the host doesn't need internet access")
})
it('does not try the host once the connect was cancelled', async () => {
const commands = freshHost(HOST_NODE_DOWNLOAD_OK)
const controller = new AbortController()
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { runProcessSync } from '@orca/process-host'
import { NODE_RUNTIME_ASSETS } from '../../../shared/node-runtime-pin'
import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN } from '../../../shared/node-runtime-pin'
import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform'
import {
hostDownloadNodeRuntimeCommand,
@@ -43,7 +43,7 @@ function fakeHost(tools: Record<string, string>): { bin: string; stage: string }
const stage = join(root, 'stage')
mkdirSync(bin)
mkdirSync(stage)
for (const basic of ['cut', 'rm', 'tail', 'sha256sum', 'shasum', 'perl']) {
for (const basic of ['cut', 'rm', 'head', 'sleep', 'kill', 'sha256sum', 'shasum', 'perl']) {
const resolved = which(basic)
if (resolved) {
symlinkSync(resolved, join(bin, basic))
@@ -59,48 +59,72 @@ function fakeHost(tools: Record<string, string>): { bin: string; stage: string }
const writeTo = (flag: string, content: string): string =>
`while [ $# -gt 0 ]; do if [ "$1" = ${flag} ]; then printf '%s' '${content}' > "$2"; fi; shift; done`
function runOnHost(bin: string, stage: string): string {
function runOnHost(
bin: string,
stage: string,
options: { mirror?: string; env?: Record<string, string>; timeoutMs?: number } = {}
): string {
const command = hostDownloadNodeRuntimeCommand(host, {
stageDir: stage,
target: TARGET,
mirror: options.mirror
})
const result = runProcessSync({
program: '/bin/sh',
args: ['-c', hostDownloadNodeRuntimeCommand(host, { stageDir: stage, target: TARGET })],
env: { PATH: bin },
timeoutMs: 30_000
args: ['-c', command],
env: { PATH: bin, ...options.env },
timeoutMs: options.timeoutMs ?? 30_000
})
expect(result.code, result.stderr).toBe(0)
return result.stdout
}
const read = (bin: string, name: string): string[] =>
runProcessSync({ program: '/bin/cat', args: [join(bin, name)] })
.stdout.trim()
.split('\n')
const mirrored = (base: string): string =>
`${base}/v${NODE_RUNTIME_PIN.version}/${originalAsset.archive}`
// Records each URL and --max-time, and serves only URLs with the given prefix.
const curlServing = (prefix: string, failure = "echo 'curl: (22) 404' >&2; exit 22"): string =>
[
`prev=; for a in "$@"; do case "$a" in http*) echo "$a" >> "\${0%/*}/urls";; esac;`,
`[ "$prev" = --max-time ] && echo "$a" >> "\${0%/*}/max-times"; prev=$a; done`,
`ulimit -f >> "\${0%/*}/file-limit"`,
`for a in "$@"; do case "$a" in ${prefix}*) ${writeTo('-o', PAYLOAD)}; exit 0;; esac; done`,
failure
].join('\n')
describe.skipIf(process.platform === 'win32')('the host-side pinned Node download', () => {
it('downloads the pinned URL with curl and keeps the verified archive', () => {
const { bin, stage } = fakeHost({
curl: `echo "$@" > "\${0%/*}/curl.args"\n${writeTo('-o', PAYLOAD)}`
})
it('downloads the release URL with curl and keeps the verified archive', () => {
const { bin, stage } = fakeHost({ curl: curlServing('https://nodejs.org/') })
expect(readHostNodeDownload(runOnHost(bin, stage))).toEqual({ ok: true })
expect(existsSync(join(stage, originalAsset.archive))).toBe(true)
expect(
runProcessSync({ program: '/bin/cat', args: [join(bin, 'curl.args')] }).stdout
).toContain(hostNodeRuntimeUrl(TARGET))
expect(read(bin, 'urls')).toEqual([hostNodeRuntimeUrl(TARGET)])
// 200 MiB in 512-byte blocks: an endless response can't fill the host's disk.
expect(read(bin, 'file-limit')).toEqual(['409600'])
})
it('falls back to wget when curl fails', () => {
const { bin, stage } = fakeHost({
curl: "echo 'curl: (35) TLS connect error' >&2; exit 35",
wget: writeTo('-O', PAYLOAD)
})
it('uses wget where the host has no curl', () => {
const { bin, stage } = fakeHost({ wget: writeTo('-O', PAYLOAD) })
expect(readHostNodeDownload(runOnHost(bin, stage))).toEqual({ ok: true })
})
it('does not start wget after curl ran out of time, so both fit the exec limit', () => {
const { bin, stage } = fakeHost({
curl: "echo 'curl: (28) Operation timed out' >&2; exit 28",
wget: `touch "\${0%/*}/wget.ran"; ${writeTo('-O', PAYLOAD)}`
})
expect(readHostNodeDownload(runOnHost(bin, stage))).toMatchObject({
ok: false,
report: expect.stringContaining('exit 28')
})
expect(existsSync(join(bin, 'wget.ran'))).toBe(false)
})
it(
'stops a trickling wget at its share of the deadline, without the timeout command',
{ timeout: 90_000 },
() => {
// Deadline 1840; every later reading leaves 31 s.
const { bin, stage } = fakeHost({
date: `if [ -e "\${0%/*}/clock" ]; then echo 1809; else : > "\${0%/*}/clock"; echo 1000; fi`,
wget: 'exec sleep 120'
})
const started = Date.now()
const result = readHostNodeDownload(runOnHost(bin, stage, { timeoutMs: 80_000 }))
expect(Date.now() - started).toBeLessThan(70_000)
expect(result).toMatchObject({ ok: false, report: expect.stringContaining('wget exit 143') })
}
)
it('discards a file that is not the pinned release', () => {
const { bin, stage } = fakeHost({ curl: writeTo('-o', '<html>blocked by policy</html>') })
@@ -109,14 +133,14 @@ describe.skipIf(process.platform === 'win32')('the host-side pinned Node downloa
expect(existsSync(join(stage, originalAsset.archive))).toBe(false)
})
it("reports the tool's own words when every download fails", () => {
it("reports each source by name with the tool's own words", () => {
const { bin, stage } = fakeHost({
curl: "echo 'curl: (7) Failed to connect to nodejs.org port 443' >&2; exit 7"
})
const result = readHostNodeDownload(runOnHost(bin, stage))
expect(result).toEqual({
expect(readHostNodeDownload(runOnHost(bin, stage))).toEqual({
ok: false,
report: 'its download failed (exit 7): curl: (7) Failed to connect to nodejs.org port 443'
report:
'its downloads failed (nodejs.org: curl exit 7, curl: (7) Failed to connect to nodejs.org port 443)'
})
})
@@ -127,4 +151,44 @@ describe.skipIf(process.platform === 'win32')('the host-side pinned Node downloa
report: 'it has neither curl nor wget'
})
})
it("tries this computer's mirror first and stops once it serves the pinned file", () => {
const { bin, stage } = fakeHost({ curl: curlServing('https://desk.example/') })
const output = runOnHost(bin, stage, { mirror: 'https://desk.example/dist' })
expect(readHostNodeDownload(output)).toEqual({ ok: true })
expect(read(bin, 'urls')).toEqual([mirrored('https://desk.example/dist')])
})
it("ignores the host's own mirror variables, so only URLs Orca validated are fetched", () => {
const { bin, stage } = fakeHost({ curl: curlServing('https://nodejs.org/') })
const output = runOnHost(bin, stage, {
mirror: 'https://desk.example/dist',
env: { NODEJS_ORG_MIRROR: 'https://user:secret@host.example/dist[' }
})
expect(readHostNodeDownload(output)).toEqual({ ok: true })
expect(read(bin, 'urls')).toEqual([
mirrored('https://desk.example/dist'),
hostNodeRuntimeUrl(TARGET)
])
})
it('splits the deadline over the sources left, so a stalled mirror leaves time for the release', () => {
const { bin, stage } = fakeHost({
date: 'echo 1000',
curl: curlServing('https://nodejs.org/', "echo 'curl: (28) timed out' >&2; exit 28")
})
const output = runOnHost(bin, stage, { mirror: 'https://desk.example/dist' })
expect(readHostNodeDownload(output)).toEqual({ ok: true })
expect(read(bin, 'max-times')).toEqual(['420', '840'])
})
it('reports a source as not tried once the deadline has passed', () => {
const { bin, stage } = fakeHost({
date: `if [ -e "\${0%/*}/clock" ]; then echo 99999; else : > "\${0%/*}/clock"; echo 1000; fi`,
curl: `touch "\${0%/*}/curl.ran"; exit 7`
})
const result = readHostNodeDownload(runOnHost(bin, stage))
expect(result).toMatchObject({ ok: false, report: expect.stringContaining('out of time') })
expect(existsSync(join(bin, 'curl.ran'))).toBe(false)
})
})
@@ -6,31 +6,37 @@
*/
import { copyFile, link, mkdtemp } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { nodeDistMirrorUrl } from '../../../shared/node-dist-mirror'
import {
NODE_RUNTIME_PIN,
nodeRuntimeReleaseUrl,
pinnedNodeRuntimeAsset,
type NodeRuntimeTarget
} from '../../../shared/node-runtime-pin'
import { quotePosixShell } from '../../../shared/shell-argument-quoting'
import {
parseRemoteRuntimeExitReport,
REMOTE_NODE_RUNTIME_EXIT_PREFIX
} from '../../ssh/orcad-remote-node-runtime-report'
import {
isWindowsRemoteHost,
joinRemotePath,
type RemoteHostPlatform
} from '../../ssh/ssh-remote-platform'
import { configuredNodeDistMirror } from '../../node-runtime/node-dist-mirror-setting'
import { DesktopDownloadUnreachableError } from '../../node-runtime/pinned-runtime-materializer'
import { remoteSha256Command } from './remote-sha256-command'
export const HOST_NODE_DOWNLOAD_OK = 'ORCA_NODE_HOST_DOWNLOAD_OK'
export const HOST_NODE_DOWNLOAD_FAILED = 'ORCA_NODE_HOST_DOWNLOAD_FAILED'
export const HOST_NODE_DOWNLOAD_NO_TOOL = 'ORCA_NODE_HOST_DOWNLOAD_NO_TOOL'
export const HOST_NODE_DOWNLOAD_HASH_MISMATCH = 'ORCA_NODE_HOST_DOWNLOAD_HASH_MISMATCH'
/** Each tool's wall-clock cap: ~140 KB/s still fetches the ~58 MB archive. */
const HOST_DOWNLOAD_TOOL_SECONDS = 420
/** Both tools plus the hash, so a tool's own limit always reports before the exec gives up. */
export const HOST_NODE_DOWNLOAD_TIMEOUT_MS = (2 * HOST_DOWNLOAD_TOOL_SECONDS + 60) * 1000
/** One wall-clock budget for every source: ~70 KB/s still fetches the ~58 MB archive. */
const HOST_DOWNLOAD_BUDGET_SECONDS = 840
/** Below this an attempt can't finish, so the source is reported as not tried. */
const HOST_DOWNLOAD_MIN_ATTEMPT_SECONDS = 30
/** The desktop downloader's 200 MiB limit, in the 512-byte blocks `ulimit -f` counts. */
const HOST_DOWNLOAD_MAX_BLOCKS = (200 * 1024 * 1024) / 512
/** The budget plus the hash and the report, so the script always answers before the exec gives up. */
export const HOST_NODE_DOWNLOAD_TIMEOUT_MS = (HOST_DOWNLOAD_BUDGET_SECONDS + 120) * 1000
const HOST_NODE_DOWNLOAD_NOT_PINNED =
'the file is not the pinned release (a proxy or filter may have replaced it, or the host lacks sha256sum and shasum)'
export function hostNodeRuntimeUrl(target: NodeRuntimeTarget): string {
const asset = pinnedNodeRuntimeAsset(target)
@@ -38,33 +44,64 @@ export function hostNodeRuntimeUrl(target: NodeRuntimeTarget): string {
}
/**
* Downloads into the installer's stage with curl, else wget, keeping the file only if it hashes
* to the pin. Always exits 0 with a marker, so the caller gets the tool's own words.
* Downloads into the installer's stage from this computer's mirror, then the release URL, with
* curl or else wget. Only URLs Orca validated are used, so no host secret can reach the report.
* Keeps the file only if it hashes to the pin, and always exits 0 with a marker.
*/
export function hostDownloadNodeRuntimeCommand(
host: RemoteHostPlatform,
args: { stageDir: string; target: NodeRuntimeTarget }
args: { stageDir: string; target: NodeRuntimeTarget; mirror?: string | null }
): string {
const asset = pinnedNodeRuntimeAsset(args.target)
const file = quotePosixShell(joinRemotePath(host, args.stageDir, asset.archive))
const url = quotePosixShell(hostNodeRuntimeUrl(args.target))
const curl = `curl -fsSL --connect-timeout 20 --max-time ${HOST_DOWNLOAD_TOOL_SECONDS} -o ${file} ${url}`
// wget has no total-time limit of its own. Probed by running it: old busybox wants `-t SECS`.
const wget = `$orca_dl_timeout wget -q -T 20 -t 2 -O ${file} ${url}`
// Mirrors carry the official tree only; unofficial builds live at one address.
const official = asset.source === 'official'
const desktopMirror =
official && args.mirror
? nodeDistMirrorUrl(args.mirror, NODE_RUNTIME_PIN.version, asset.archive)
: null
const sources = [
desktopMirror
? `orca_dl_get 'the mirror set on this computer' ${quotePosixShell(desktopMirror)}`
: null,
`orca_dl_get ${quotePosixShell(new URL(hostNodeRuntimeUrl(args.target)).host)} ${quotePosixShell(hostNodeRuntimeUrl(args.target))}`
].filter((line): line is string => line !== null)
const min = HOST_DOWNLOAD_MIN_ATTEMPT_SECONDS
// Why ulimit: a proxy or mirror serving endless bytes would otherwise fill the host's disk.
const sizeCap = `ulimit -f ${HOST_DOWNLOAD_MAX_BLOCKS} 2>/dev/null`
// wget has no total-time limit, and `timeout` varies by host, so a backgrounded sleep bounds it.
// The watchdog's output goes to /dev/null so it never holds the capture or the SSH channel open.
const wget =
`wget -q -T 20 -t 1 -O ${file} "$2" & orca_dl_pid=$!; ` +
// The trap takes the sleep down with the watchdog, so nothing lingers once wget is done.
`(trap 'kill "$orca_dl_z" 2>/dev/null; exit 0' TERM; sleep "$orca_dl_t" & orca_dl_z=$!; ` +
'wait "$orca_dl_z" && kill "$orca_dl_pid") </dev/null >/dev/null 2>&1 & orca_dl_dog=$!; ' +
'wait "$orca_dl_pid"; orca_dl_s=$?; kill "$orca_dl_dog" 2>/dev/null; exit "$orca_dl_s"'
const curl = `curl -fsSLg --connect-timeout 20 --max-time "$orca_dl_t" -o ${file} "$2"`
return [
'orca_dl_status=127; orca_dl_out=; orca_dl_timeout=',
`timeout 5 true >/dev/null 2>&1 && orca_dl_timeout="timeout ${HOST_DOWNLOAD_TOOL_SECONDS}"`,
`if command -v curl >/dev/null 2>&1; then orca_dl_out=$(${curl} 2>&1); orca_dl_status=$?; fi`,
// Why wget after a failed curl: a curl built without TLS or proxy support can fail where wget
// works. Not after curl's timeout (28): the link is just slow, and wget would be too.
`if [ "$orca_dl_status" != 0 ] && [ "$orca_dl_status" != 28 ] && command -v wget >/dev/null 2>&1; then ` +
`orca_dl_out="$orca_dl_out\${orca_dl_out:+; }$(${wget} 2>&1)"; orca_dl_status=$?; fi`,
`if [ "$orca_dl_status" = 127 ] && [ -z "$orca_dl_out" ]; then echo ${HOST_NODE_DOWNLOAD_NO_TOOL}`,
`elif [ "$orca_dl_status" != 0 ]; then rm -f ${file}; echo ${HOST_NODE_DOWNLOAD_FAILED}; ` +
`echo "${REMOTE_NODE_RUNTIME_EXIT_PREFIX}$orca_dl_status"; printf '%s\\n' "$orca_dl_out" | tail -c 2000`,
`elif [ "$(${remoteSha256Command(file)})" != ${quotePosixShell(asset.archiveSha256)} ]; then ` +
`rm -f ${file}; echo ${HOST_NODE_DOWNLOAD_HASH_MISMATCH}`,
`else echo ${HOST_NODE_DOWNLOAD_OK}; fi`
'orca_dl_ok=; orca_dl_out=; orca_dl_tool=',
'if command -v curl >/dev/null 2>&1; then orca_dl_tool=curl; elif command -v wget >/dev/null 2>&1; then orca_dl_tool=wget; fi',
`orca_dl_n=${sources.length}`,
'orca_dl_end=$(date +%s 2>/dev/null)',
`case "$orca_dl_end" in ''|*[!0-9]*) orca_dl_end= ;; *) orca_dl_end=$((orca_dl_end + ${HOST_DOWNLOAD_BUDGET_SECONDS})) ;; esac`,
// Each source gets an even share of what is left, so a stalled mirror can't starve the release
// URL, and the last source inherits whatever the others didn't use.
'orca_dl_get() {',
' [ -z "$orca_dl_ok" ] || return 0',
` if [ -n "$orca_dl_end" ]; then orca_dl_t=$(((orca_dl_end - $(date +%s)) / orca_dl_n)); else orca_dl_t=${Math.floor(HOST_DOWNLOAD_BUDGET_SECONDS / sources.length)}; fi`,
' orca_dl_n=$((orca_dl_n - 1))',
` if [ "$orca_dl_t" -lt ${min} ]; then orca_dl_out="$orca_dl_out\${orca_dl_out:+; }$1: not tried, out of time"; return 0; fi`,
` if [ "$orca_dl_tool" = curl ]; then orca_dl_said=$( (${sizeCap}; ${curl}) 2>&1 ); orca_dl_try=$?`,
` else orca_dl_said=$( (${sizeCap}; ${wget}) 2>&1 ); orca_dl_try=$?; fi`,
` if [ "$orca_dl_try" = 0 ] && [ "$(${remoteSha256Command(file)})" = ${quotePosixShell(asset.archiveSha256)} ]; then orca_dl_ok=1; return 0; fi`,
` rm -f ${file}`,
` [ "$orca_dl_try" = 0 ] && orca_dl_said='${HOST_NODE_DOWNLOAD_NOT_PINNED}'`,
' orca_dl_out="$orca_dl_out${orca_dl_out:+; }$1: $orca_dl_tool exit $orca_dl_try${orca_dl_said:+, $orca_dl_said}"',
'}',
`if [ -z "$orca_dl_tool" ]; then echo ${HOST_NODE_DOWNLOAD_NO_TOOL}; else`,
...sources,
`if [ -n "$orca_dl_ok" ]; then echo ${HOST_NODE_DOWNLOAD_OK}; else echo ${HOST_NODE_DOWNLOAD_FAILED}; printf '%s\\n' "$orca_dl_out" | head -c 3000; fi`,
'fi'
].join('\n')
}
@@ -79,20 +116,10 @@ export function readHostNodeDownload(output: string): HostNodeDownloadResult {
if (lines.some((line) => line.trim() === HOST_NODE_DOWNLOAD_NO_TOOL)) {
return { ok: false, report: 'it has neither curl nor wget' }
}
if (lines.some((line) => line.trim() === HOST_NODE_DOWNLOAD_HASH_MISMATCH)) {
return {
ok: false,
report:
"it couldn't verify the file as the pinned release, so it was discarded (a proxy or filter may have replaced it, or the host lacks sha256sum and shasum)"
}
}
const failed = output.indexOf(HOST_NODE_DOWNLOAD_FAILED)
if (failed !== -1) {
const { exitStatus, output: words } = parseRemoteRuntimeExitReport(output.slice(failed))
return {
ok: false,
report: `its download failed (exit ${exitStatus ?? 'unknown'})${words ? `: ${words}` : ''}`
}
const words = output.slice(failed + HOST_NODE_DOWNLOAD_FAILED.length).trim()
return { ok: false, report: `its downloads failed${words ? ` (${words})` : ''}` }
}
return { ok: false, report: `it gave an unexpected reply: ${output.trim().slice(0, 500)}` }
}
@@ -103,16 +130,26 @@ export class NodeRuntimeUnreachableError extends Error {
readonly url: string,
readonly hostReport: string,
readonly offlineArchivePath: string | undefined,
cause: unknown
cause: unknown,
mirrorable = true
) {
const desktop = cause instanceof Error ? cause.message : String(cause)
const desktop =
cause instanceof DesktopDownloadUnreachableError
? cause.reason
: cause instanceof Error
? cause.message
: String(cause)
const mirror = mirrorable
? ', set a mirror of nodejs.org/dist in Settings > Advanced > Network > Node.js download mirror ' +
"(or NODEJS_ORG_MIRROR in this computer's environment)"
: ''
const offline = offlineArchivePath
? `, or download that file on another machine and copy it to ${offlineArchivePath} on this computer.`
: '.'
super(
`Neither this computer nor the host could download Node from ${url}. ` +
`This computer: ${desktop}. The host: ${hostReport}. ` +
`Check the network or proxy on either machine and connect again${offline}`,
`Check the network or proxy on either machine and connect again${mirror}${offline}`,
{ cause }
)
this.name = 'NodeRuntimeUnreachableError'
@@ -134,7 +171,11 @@ export async function downloadNodeRuntimeOnHost(args: {
signal?: AbortSignal
}): Promise<string> {
const output = await args.exec(
hostDownloadNodeRuntimeCommand(args.host, { stageDir: args.stageDir, target: args.target }),
hostDownloadNodeRuntimeCommand(args.host, {
stageDir: args.stageDir,
target: args.target,
mirror: configuredNodeDistMirror()
}),
{ signal: args.signal, timeoutMs: args.timeoutMs }
)
const result = readHostNodeDownload(output)
@@ -143,7 +184,8 @@ export async function downloadNodeRuntimeOnHost(args: {
hostNodeRuntimeUrl(args.target),
result.report,
args.offlineArchivePath,
args.desktopError
args.desktopError,
pinnedNodeRuntimeAsset(args.target).source === 'official'
)
}
console.warn(
@@ -0,0 +1,15 @@
/** This computer's Node mirror: the Settings value, else the conventional environment variables. */
import { resolveNodeDistMirror } from '../../shared/node-dist-mirror'
let readMirrorSetting: (() => string | undefined) | null = null
/** Registered once the settings store exists; read on every download, so edits apply at once. */
export function setNodeDistMirrorSettingReader(read: (() => string | undefined) | null): void {
readMirrorSetting = read
}
export function configuredNodeDistMirror(
env: Record<string, string | undefined> = process.env
): string | null {
return resolveNodeDistMirror(readMirrorSetting?.(), env)
}
@@ -0,0 +1,117 @@
import { createHash } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin'
import {
DesktopDownloadUnreachableError,
materializeNodeRuntimeArchive
} from './pinned-runtime-materializer'
import { ReleaseMismatchError } from './runtime-archive-download'
import { setNodeDistMirrorSettingReader } from './node-dist-mirror-setting'
const TARGET = 'linux-x64-glibc'
const MIRROR = 'https://artifacts.example.internal/nodejs/dist'
const BODY = new TextEncoder().encode('pinned archive')
const originalAsset = { ...NODE_RUNTIME_ASSETS[TARGET] }
const mirrorUrl = `${MIRROR}/v${NODE_RUNTIME_PIN.version}/${originalAsset.archive}`
let root = ''
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-node-mirror-'))
Object.assign(NODE_RUNTIME_ASSETS[TARGET], {
archiveSha256: createHash('sha256').update(BODY).digest('hex')
})
setNodeDistMirrorSettingReader(() => `${MIRROR}/`)
})
afterEach(async () => {
setNodeDistMirrorSettingReader(null)
Object.assign(NODE_RUNTIME_ASSETS[TARGET], originalAsset)
await rm(root, { recursive: true, force: true })
})
function fetcher(serves: (url: string) => boolean) {
return vi.fn<typeof fetch>(async (url) =>
serves(String(url))
? new Response(Buffer.from(BODY), { status: 200 })
: new Response('not here', { status: 404 })
)
}
describe('the pinned Node download with a mirror configured', () => {
it('downloads from the mirror and never asks nodejs.org', async () => {
const fetch = fetcher((url) => url === mirrorUrl)
await materializeNodeRuntimeArchive(TARGET, root, { fetcher: fetch })
expect(fetch.mock.calls.map(([url]) => String(url))).toEqual([mirrorUrl])
})
it('falls back to the release URL when the mirror lacks the file', async () => {
const fetch = fetcher((url) => url.startsWith('https://nodejs.org/'))
await materializeNodeRuntimeArchive(TARGET, root, { fetcher: fetch })
const urls = fetch.mock.calls.map(([url]) => String(url))
expect(urls[0]).toBe(mirrorUrl)
expect(urls.at(-1)).toMatch(/^https:\/\/nodejs\.org\/dist\//)
})
it('tries an unreachable mirror once, without its retry pauses, before nodejs.org', async () => {
const fetch = vi.fn<typeof fetch>(async (url) => {
if (String(url) === mirrorUrl) {
throw new Error('net::ERR_CONNECTION_TIMED_OUT')
}
return new Response(Buffer.from(BODY), { status: 200 })
})
await materializeNodeRuntimeArchive(TARGET, root, { fetcher: fetch })
expect(fetch.mock.calls.map(([url]) => String(url) === mirrorUrl)).toEqual([true, false])
})
it('rejects a mirror file that fails the pin, whatever the mirror says', async () => {
const fetch = vi.fn<typeof fetch>(
async () => new Response('<html>blocked</html>', { status: 200 })
)
await expect(
materializeNodeRuntimeArchive(TARGET, root, { fetcher: fetch })
).rejects.toBeInstanceOf(ReleaseMismatchError)
})
it('names both addresses when neither is reachable', async () => {
const fetch = vi.fn<typeof fetch>(async () => {
throw new Error('net::ERR_CONNECTION_REFUSED')
})
const error = await materializeNodeRuntimeArchive(TARGET, root, {
fetcher: fetch,
retryDelaysMs: []
}).catch((failure: unknown) => failure)
expect(error).toBeInstanceOf(DesktopDownloadUnreachableError)
expect(String(error)).toMatch(/the mirror .* failed too/)
})
it('keeps a cancel during the release attempt a cancel', async () => {
const controller = new AbortController()
const fetch = vi.fn<typeof fetch>(async (url) => {
if (String(url) !== mirrorUrl) {
controller.abort()
}
throw new Error('net::ERR_CONNECTION_REFUSED')
})
const error = await materializeNodeRuntimeArchive(TARGET, root, {
fetcher: fetch,
signal: controller.signal,
retryDelaysMs: []
}).catch((failure: unknown) => failure)
expect(error).not.toBeInstanceOf(DesktopDownloadUnreachableError)
expect(controller.signal.aborted).toBe(true)
})
})
@@ -11,7 +11,10 @@ import {
} from './runtime-archive-download'
import { findOrcadCachePath } from './orcad-cache-path'
import { orcadNodeRuntimeExecutable } from '../../shared/orcad-artifacts'
import { nodeDistMirrorUrl } from '../../shared/node-dist-mirror'
import { configuredNodeDistMirror } from './node-dist-mirror-setting'
import {
NODE_RUNTIME_PIN,
pinnedNodeRuntimeAsset,
nodeRuntimeExecutablePath,
nodeRuntimeReleaseUrl,
@@ -21,6 +24,7 @@ import {
export type PinnedRuntimeMaterializeOptions = {
fetcher?: MainHttpClient['fetch']
signal?: AbortSignal
/** Pauses between download attempts; empty makes a single attempt. */
retryDelaysMs?: readonly number[]
}
@@ -104,16 +108,30 @@ function unreachableArchiveError(
return error
}
const cause = error instanceof Error ? error.message : String(error)
return new Error(
return new DesktopDownloadUnreachableError(
`This computer couldn't download ${archive.label} from ${archive.url} (${cause}). ` +
`Orca uploads ${archive.label} to the host from this computer, so the host doesn't need ` +
`internet access, but this computer must reach that address once. Check this computer's ` +
`network connection or proxy and connect again, or download that file on another machine ` +
`network connection or proxy and connect again, set a mirror in Settings > Advanced > ` +
`Network > Node.js download mirror, or download that file on another machine ` +
`and copy it to ${cachePath}`,
{ cause: error }
`${archive.url}: ${cause}`,
error
)
}
/** This computer's download failed on the network; `reason` is the short form for combined errors. */
export class DesktopDownloadUnreachableError extends Error {
constructor(
message: string,
readonly reason: string,
cause: unknown
) {
super(message, { cause })
this.name = 'DesktopDownloadUnreachableError'
}
}
export async function materializeCachedRuntimeExecutable(
runtime: PinnedRuntimeExecutable,
options: PinnedRuntimeMaterializeOptions
@@ -199,16 +217,61 @@ function nodeRuntimeArchive(target: NodeRuntimeTarget): PinnedRuntimeArchive {
}
}
/** Tries the configured mirror before the release URL; the pinned hash decides what is kept. */
async function withNodeDistMirror<T>(
target: NodeRuntimeTarget,
options: PinnedRuntimeMaterializeOptions,
run: (archive: PinnedRuntimeArchive, options: PinnedRuntimeMaterializeOptions) => Promise<T>
): Promise<T> {
const archive = nodeRuntimeArchive(target)
const asset = pinnedNodeRuntimeAsset(target)
// Mirrors carry the official tree only; unofficial builds live at one address.
const mirror = asset.source === 'official' ? configuredNodeDistMirror() : null
if (!mirror) {
return run(archive, options)
}
const mirrorUrl = nodeDistMirrorUrl(mirror, NODE_RUNTIME_PIN.version, asset.archive)
try {
// One attempt: an unreachable mirror must not hold up the release URL behind its retries.
return await run({ ...archive, url: mirrorUrl }, { ...options, retryDelaysMs: [] })
} catch (mirrorError) {
if (options.signal?.aborted) {
throw mirrorError
}
return run(archive, options).catch((error: unknown) => {
// Only two network failures combine; a cancel or a local error keeps its own identity.
if (options.signal?.aborted || !(error instanceof DesktopDownloadUnreachableError)) {
throw error
}
const mirrorReason =
mirrorError instanceof DesktopDownloadUnreachableError
? mirrorError.reason
: `${mirrorUrl}: ${errorText(mirrorError)}`
throw new DesktopDownloadUnreachableError(
`${errorText(error)} (the mirror ${mirrorReason} failed too)`,
`${mirrorReason}; ${errorReason(error)}`,
error
)
})
}
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function errorReason(error: unknown): string {
return error instanceof DesktopDownloadUnreachableError ? error.reason : errorText(error)
}
/** The pinned official archive, uploaded to hosts as published (design D5). */
export function materializeNodeRuntimeArchive(
target: NodeRuntimeTarget,
cacheRoot: string,
options: PinnedRuntimeMaterializeOptions
): Promise<string> {
return materializeVerifiedRuntimeArchive(
nodeRuntimeArchive(target),
nodeRuntimeArchiveDir(cacheRoot),
options
return withNodeDistMirror(target, options, (archive, download) =>
materializeVerifiedRuntimeArchive(archive, nodeRuntimeArchiveDir(cacheRoot), download)
)
}
@@ -232,21 +295,23 @@ export function materializeCachedNodeRuntime(
): Promise<string> {
const asset = pinnedNodeRuntimeAsset(target)
const executableName = basename(orcadNodeRuntimeExecutable(target))
return materializeCachedRuntimeExecutable(
{
...nodeRuntimeArchive(target),
executableSha256: asset.executableSha256,
member: nodeRuntimeExecutablePath(target, asset.archive),
isWindows: target.startsWith('win32-'),
cachePath: (attempt) =>
join(
cacheRoot,
'node',
asset.executableSha256,
`${attempt ? `repair-${attempt}-` : ''}${executableName}`
)
},
options
return withNodeDistMirror(target, options, (archive, download) =>
materializeCachedRuntimeExecutable(
{
...archive,
executableSha256: asset.executableSha256,
member: nodeRuntimeExecutablePath(target, asset.archive),
isWindows: target.startsWith('win32-'),
cachePath: (attempt) =>
join(
cacheRoot,
'node',
asset.executableSha256,
`${attempt ? `repair-${attempt}-` : ''}${executableName}`
)
},
download
)
)
}
@@ -4,6 +4,7 @@ import { applyBackgroundActivationPolicy } from '../window/foreground-activation
import { applyElectronProxySettings } from '../network/proxy-settings'
import { installElectronProxyRequestGuard } from '../network/electron-proxy-request-guard'
import { handleElectronProxyLogin } from '../network/electron-proxy-credentials'
import { setNodeDistMirrorSettingReader } from '../node-runtime/node-dist-mirror-setting'
import { handleBrowserGuestLogin } from '../browser/browser-http-auth-prompt'
import { installMainThreadHangWatchdog } from '../hang-watchdog/main-thread-hang-watchdog'
import { preservePreviousHangDetection } from '../hang-watchdog/previous-hang-detection'
@@ -149,6 +150,7 @@ export async function initializeReadyFoundation(): Promise<void> {
}
const store = profileState.store
state.store = store
setNodeDistMirrorSettingReader(() => store.getSettings().nodeDistMirrorUrl)
// Why before any handler or runtime: a project a WSL move is taking must never get a new terminal.
loadWslMoveFence(getAppEnvironment().getPath('userData'))
// Finishes a move that committed before a crash; gated off it does nothing, and it never throws.
@@ -61,3 +61,65 @@ describe('AdvancedNetworkSettingsSection bypass rules control', () => {
expect(updateSettings).not.toHaveBeenCalled()
})
})
describe('AdvancedNetworkSettingsSection Node.js download mirror', () => {
it('shows a saved mirror and commits a normalized new one on blur', () => {
const updateSettings = vi.fn()
const { container } = render(
<AdvancedNetworkSettingsSection
settings={{ ...getDefaultSettings('/tmp'), nodeDistMirrorUrl: 'https://old.example/dist' }}
updateSettings={updateSettings}
/>
)
const input = container.querySelector<HTMLInputElement>('#settings-node-dist-mirror-url')!
expect(input.value).toBe('https://old.example/dist')
fireEvent.change(input, { target: { value: ' https://npmmirror.com/mirrors/node/ ' } })
fireEvent.blur(input)
expect(updateSettings).toHaveBeenCalledWith({
nodeDistMirrorUrl: 'https://npmmirror.com/mirrors/node'
})
})
it('keeps an invalid mirror out of settings and says why', () => {
const updateSettings = vi.fn()
const { container } = render(
<AdvancedNetworkSettingsSection
settings={{ ...getDefaultSettings('/tmp'), nodeDistMirrorUrl: 'https://old.example/dist' }}
updateSettings={updateSettings}
/>
)
const input = container.querySelector<HTMLInputElement>('#settings-node-dist-mirror-url')!
fireEvent.change(input, { target: { value: 'https://user:pw@mirror.example/dist' } })
fireEvent.blur(input)
expect(updateSettings).not.toHaveBeenCalled()
expect(container.textContent).toContain('cannot contain a user name or password')
})
it('follows settings reloads A to B and back to A', () => {
const updateSettings = vi.fn()
const base = getDefaultSettings('/tmp')
const a = 'https://a.example/dist'
const b = 'https://b.example/dist'
const view = (mirror: string) => (
<AdvancedNetworkSettingsSection
settings={{ ...base, nodeDistMirrorUrl: mirror }}
updateSettings={updateSettings}
/>
)
const { container, rerender } = render(view(a))
const input = () => container.querySelector<HTMLInputElement>('#settings-node-dist-mirror-url')!
fireEvent.change(input(), { target: { value: b } })
fireEvent.blur(input())
rerender(view(b))
rerender(view(a))
expect(input().value).toBe(a)
fireEvent.blur(input())
expect(updateSettings).toHaveBeenCalledTimes(1)
})
})
@@ -11,6 +11,7 @@ import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Textarea } from '../ui/textarea'
import { getAdvancedNetworkSearchEntries } from './advanced-network-search'
import { NodeDistMirrorSettingField } from './NodeDistMirrorSettingField'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
import { translate } from '@/i18n/i18n'
@@ -25,7 +26,11 @@ export function shouldOpenNetworkProxyConfig(searchQuery: string): boolean {
/** A configured proxy should also reveal the fields, so users see the value. */
export function hasConfiguredNetworkProxy(settings: GlobalSettings): boolean {
return Boolean(settings.httpProxyUrl?.trim() || settings.httpProxyBypassRules?.trim())
return Boolean(
settings.httpProxyUrl?.trim() ||
settings.httpProxyBypassRules?.trim() ||
settings.nodeDistMirrorUrl?.trim()
)
}
export type HttpProxyUrlDraftState = {
@@ -216,7 +221,9 @@ export function AdvancedNetworkSettingsSection({
'no_proxy',
'network',
'bypass',
'localhost'
'localhost',
'mirror',
'nodejs'
]}
className="space-y-3"
>
@@ -328,6 +335,11 @@ export function AdvancedNetworkSettingsSection({
)}
</p>
</div>
<NodeDistMirrorSettingField
value={settings.nodeDistMirrorUrl}
onCommit={(nodeDistMirrorUrl) => updateSettings({ nodeDistMirrorUrl })}
/>
</div>
</CollapsibleContent>
</Collapsible>
@@ -0,0 +1,82 @@
import type React from 'react'
import { useState } from 'react'
import { normalizeNodeDistMirrorUrl } from '../../../../shared/node-dist-mirror'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { translate } from '@/i18n/i18n'
type MirrorDraft = { source: string; text: string; error: string | null }
type NodeDistMirrorSettingFieldProps = {
value: string | undefined
onCommit: (value: string) => void
}
/** Where Orca downloads the Node it installs on SSH and WSL servers, when nodejs.org is out of reach. */
export function NodeDistMirrorSettingField({
value,
onCommit
}: NodeDistMirrorSettingFieldProps): React.JSX.Element {
const source = value ?? ''
const [draft, setDraft] = useState<MirrorDraft>({ source, text: source, error: null })
// Why stored, not derived: a draft left keyed to an old value would match again if it returned.
const current: MirrorDraft =
draft.source === source ? draft : { source, text: source, error: null }
if (current !== draft) {
setDraft(current)
}
const commit = (): void => {
const normalized = normalizeNodeDistMirrorUrl(current.text)
if (!normalized.ok) {
setDraft({ ...current, error: normalized.message })
return
}
setDraft({ source, text: normalized.value, error: null })
if (normalized.value !== source) {
onCommit(normalized.value)
}
}
return (
<div className="space-y-2">
<Label htmlFor="settings-node-dist-mirror-url">
{translate(
'auto.components.settings.AdvancedNetworkSettingsSection.nodeDistMirrorLabel',
'Node.js Download Mirror'
)}
</Label>
<Input
id="settings-node-dist-mirror-url"
value={current.text}
onChange={(e) => setDraft({ ...current, text: e.target.value, error: null })}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
}
}}
placeholder={translate(
'auto.components.settings.AdvancedNetworkSettingsSection.nodeDistMirrorPlaceholder',
'https://mirror.example.com/nodejs/dist'
)}
autoCapitalize="none"
autoCorrect="off"
autoComplete="off"
spellCheck={false}
aria-invalid={current.error ? true : undefined}
className="font-mono text-xs"
/>
{current.error ? (
<p className="text-xs text-destructive">{current.error}</p>
) : (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.AdvancedNetworkSettingsSection.nodeDistMirrorDescription',
'Optional. A copy of nodejs.org/dist that Orca uses first when it installs Node on SSH and WSL servers. Each file is still checked against the release Orca expects.'
)}
</p>
)}
</div>
)
}
@@ -23,7 +23,9 @@ export const getAdvancedNetworkSearchEntries = createLocalizedCatalog((): Settin
...translateSearchKeyword('auto.components.settings.general.search.91a46caafc', 'no_proxy'),
...translateSearchKeyword('auto.components.settings.general.search.3a73054565', 'bypass'),
...translateSearchKeyword('auto.components.settings.general.search.3566fce83f', 'localhost'),
...translateSearchKeyword('auto.components.settings.general.search.c56cb6f1c2', 'network')
...translateSearchKeyword('auto.components.settings.general.search.c56cb6f1c2', 'network'),
...translateSearchKeyword('auto.components.settings.general.search.nodeDistMirror', 'mirror'),
...translateSearchKeyword('auto.components.settings.general.search.nodeDistNodejs', 'nodejs')
]
}
])
+5
View File
@@ -7762,6 +7762,9 @@
"823e0f15b1": "Proxy URL for Orca network requests and local terminal children.",
"d93c7cd531": "Configure app-level network routing.",
"c46cdbbd4e": "Network",
"nodeDistMirrorLabel": "Node.js Download Mirror",
"nodeDistMirrorPlaceholder": "https://mirror.example.com/nodejs/dist",
"nodeDistMirrorDescription": "Optional. A copy of nodejs.org/dist that Orca uses first when it installs Node on SSH and WSL servers. Each file is still checked against the release Orca expects.",
"configureProxy": "Configure proxy"
},
"GeneralPane": {
@@ -10289,6 +10292,8 @@
"e9d948d3c3": "Save editor and editable diff changes automatically after a short pause.",
"ae21e806ce": "Auto Save Files",
"c56cb6f1c2": "network",
"nodeDistMirror": "mirror",
"nodeDistNodejs": "nodejs",
"3566fce83f": "localhost",
"91a46caafc": "no_proxy",
"3a73054565": "bypass",
+1
View File
@@ -124,6 +124,7 @@ export function buildDefaultSettings(args: {
terminalScrollbackRows: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT,
httpProxyUrl: '',
httpProxyBypassRules: '',
nodeDistMirrorUrl: '',
electronHttp1CompatibilityMode: false,
openLinksInApp: false,
localhostWorktreeLabelsEnabled: false,
+2
View File
@@ -202,6 +202,8 @@ export type GlobalSettings = GlobalSettingsBase & {
httpProxyUrl?: string
/** Optional semicolon/comma/newline-separated bypass rules for httpProxyUrl. */
httpProxyBypassRules?: string
/** Optional mirror of nodejs.org/dist for the Node runtime Orca installs on SSH and WSL servers. */
nodeDistMirrorUrl?: string
/** Why: corporate TLS-intercepting proxies can break HTTP/2 downloads; opt-in Chromium process-wide HTTP/1.1 switch. */
electronHttp1CompatibilityMode?: boolean
/** Opt-in in-app browsing (isolated guest surface); default keeps links opening in the system browser. */
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { normalizeNodeDistMirrorUrl, resolveNodeDistMirror } from './node-dist-mirror'
describe('Node dist mirror URL', () => {
it('keeps an http(s) base and drops trailing slashes', () => {
expect(normalizeNodeDistMirrorUrl(' https://npmmirror.com/mirrors/node/ ')).toEqual({
ok: true,
value: 'https://npmmirror.com/mirrors/node'
})
expect(normalizeNodeDistMirrorUrl('')).toEqual({ ok: true, value: '' })
for (const empty of [
'https://m.example/dist?',
'https://m.example/dist#',
'https://m.example/dist/?#'
]) {
expect(normalizeNodeDistMirrorUrl(empty)).toEqual({
ok: true,
value: 'https://m.example/dist'
})
}
})
it.each([
['ftp://mirror.example.com/node', 'http or https'],
['https://user:secret@mirror.example.com/node', 'user name or password'],
['https://mirror.example.com/node?token=1', 'query'],
['not a url', 'valid mirror URL']
])('refuses %s', (value, message) => {
expect(normalizeNodeDistMirrorUrl(value)).toMatchObject({
ok: false,
message: expect.stringContaining(message)
})
})
it('prefers the setting, then NODEJS_ORG_MIRROR, then NVM_NODEJS_ORG_MIRROR', () => {
const env = {
NODEJS_ORG_MIRROR: 'https://a.example/dist',
NVM_NODEJS_ORG_MIRROR: 'https://b.example/dist'
}
expect(resolveNodeDistMirror('https://setting.example/dist', env)).toBe(
'https://setting.example/dist'
)
expect(resolveNodeDistMirror('', env)).toBe('https://a.example/dist')
expect(resolveNodeDistMirror(undefined, { NVM_NODEJS_ORG_MIRROR: 'https://b.example/' })).toBe(
'https://b.example'
)
expect(resolveNodeDistMirror('nonsense', {})).toBeNull()
})
})
+60
View File
@@ -0,0 +1,60 @@
/**
* A mirror of nodejs.org/dist (an internal artifact proxy, or a regional mirror) for the pinned
* Node runtime. It only changes where bytes come from: the SHA-256 pin decides what is accepted.
*/
/** The variables Node tooling already reads for the same mirror; read on this computer only. */
export const NODE_DIST_MIRROR_ENV_KEYS = ['NODEJS_ORG_MIRROR', 'NVM_NODEJS_ORG_MIRROR'] as const
export type NodeDistMirrorValidationResult =
| { ok: true; value: string; message?: undefined }
| { ok: false; value: ''; message: string }
const NODE_DIST_MIRROR_MAX_LENGTH = 2048
/** An http(s) base URL with no query or fragment and no trailing slash; empty means none. */
export function normalizeNodeDistMirrorUrl(value: unknown): NodeDistMirrorValidationResult {
const trimmed = typeof value === 'string' ? value.trim() : ''
if (!trimmed) {
return { ok: true, value: '' }
}
if (trimmed.length > NODE_DIST_MIRROR_MAX_LENGTH) {
return { ok: false, value: '', message: 'Mirror URL is too long.' }
}
let parsed: URL
try {
parsed = new URL(trimmed)
} catch {
return { ok: false, value: '', message: 'Enter a valid mirror URL.' }
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return { ok: false, value: '', message: 'Use an http or https mirror URL.' }
}
// Why: the URL reaches SSH hosts and error text; credentials belong in the proxy setting.
if (parsed.username || parsed.password) {
return { ok: false, value: '', message: 'Mirror URL cannot contain a user name or password.' }
}
if (parsed.search || parsed.hash) {
return { ok: false, value: '', message: 'Mirror URL cannot have a query or fragment.' }
}
// Why origin + pathname: href keeps an empty `?` or `#`, which would swallow the archive path.
return { ok: true, value: `${parsed.origin}${parsed.pathname}`.replace(/\/+$/u, '') }
}
/** The first valid mirror among Orca's setting and the conventional environment variables. */
export function resolveNodeDistMirror(
setting: string | undefined,
env: Record<string, string | undefined>
): string | null {
for (const candidate of [setting, ...NODE_DIST_MIRROR_ENV_KEYS.map((key) => env[key])]) {
const normalized = normalizeNodeDistMirrorUrl(candidate)
if (normalized.ok && normalized.value) {
return normalized.value
}
}
return null
}
export function nodeDistMirrorUrl(mirror: string, version: string, file: string): string {
return `${mirror}/v${version}/${file}`
}