Plan Checker, Wochenbericht-Fokus-Modus und Wandtafel-Dashboard-Feinschliff

Plan Checker (neues Studenten-Werkzeug, Seitenleiste + Admin → Plan-Checker-
Merkmale): lädt einen Bauplan als PDF hoch und lässt ChatGPT oder Claude ihn
gegen eine gepflegte Merkmalsliste prüfen. PDF-Rendering (pdf.js +
@napi-rs/canvas) berücksichtigt jetzt Ebenen (Optional Content Groups) und
WASM-Decoder korrekt, sendet eine Übersicht plus mehrere hochaufgelöste
Kachel-Ausschnitte statt eines einzelnen, zu groben Bilds, und beide Anbieter
bekommen dasselbe Bildmaterial. Jedes Prüfergebnis kann auf eines der
gesendeten Bilder als Beleg verweisen (bildIndex) — als Screenshot im
Ergebnis anklickbar, öffnet vergrößert in einem Modal statt eines von Chrome
blockierten target=_blank-Links auf eine data:-URL. KI-Modellname ist jetzt
pro Konto überschreibbar (Einstellungen → Mein Konto) für Drittanbieter mit
abweichendem Modell-Katalog.

Wochenbericht: dichte Kartenansicht bekommt einen Fokus-Modus — nur der
Kommentar liegt offen, Leistungsphasen und Beteiligte stecken in
aufklappbaren Abschnitten statt dauerhaft nebeneinander zu stehen.

Projektbereich: Terminplan-Auslastung rechnet jetzt exakt wie der
Auslastungs-Tab/die Wandtafel (statt einer unabhängigen, tageweisen
Schätzung); die LPH-Fortschrittsliste im Dashboard zeigt nur noch die
beauftragten Leistungsphasen.

Wandtafel: Dashboard-Kopf mit Kennzahlen-Reihe und Panel-Reihe neu
strukturiert, in den Blautönen des Firmenlogos; dieselbe Palette wurde
anschließend auf den gesamten App-weiten Dark Mode übernommen.

Dateibereinigung: nicht mehr benötigte Test-Uploads/Backups entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Sonnet 5 committed 2026-10-05 14:01:41 +02:00
1 parent db63a29365
commit 6ba93b2a92
308 files changed
+64441 -743

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
'use strict'
const { InvalidArgumentError, MaxOriginsReachedError } = require('../core/errors')
const { kBusy, kClients, kConnected, kRunning, kPending, kClose, kDestroy, kDispatch, kUrl } = require('../core/symbols')
const DispatcherBase = require('./dispatcher-base')
const Pool = require('./pool')
const Client = require('./client')
const util = require('../core/util')
const kOnConnect = Symbol('onConnect')
const kOnDisconnect = Symbol('onDisconnect')
const kOnConnectionError = Symbol('onConnectionError')
const kOnDrain = Symbol('onDrain')
const kFactory = Symbol('factory')
const kOptions = Symbol('options')
const kOrigins = Symbol('origins')
function defaultFactory (origin, opts) {
return opts && opts.connections === 1
? new Client(origin, opts)
: new Pool(origin, opts)
}
class Agent extends DispatcherBase {
constructor ({ factory = defaultFactory, maxOrigins = Infinity, connect, ...options } = {}) {
if (typeof factory !== 'function') {
throw new InvalidArgumentError('factory must be a function.')
}
if (connect != null && typeof connect !== 'function' && typeof connect !== 'object') {
throw new InvalidArgumentError('connect must be a function or an object')
}
if (typeof maxOrigins !== 'number' || Number.isNaN(maxOrigins) || maxOrigins <= 0) {
throw new InvalidArgumentError('maxOrigins must be a number greater than 0')
}
super(options)
if (connect && typeof connect !== 'function') {
connect = { ...connect }
}
this[kOptions] = { ...util.deepClone(options), maxOrigins, connect }
this[kFactory] = factory
this[kClients] = new Map()
this[kOrigins] = new Set()
this[kOnDrain] = (origin, targets) => {
this.emit('drain', origin, [this, ...targets])
}
this[kOnConnect] = (origin, targets) => {
this.emit('connect', origin, [this, ...targets])
}
this[kOnDisconnect] = (origin, targets, err) => {
this.emit('disconnect', origin, [this, ...targets], err)
}
this[kOnConnectionError] = (origin, targets, err) => {
this.emit('connectionError', origin, [this, ...targets], err)
}
}
get [kRunning] () {
let ret = 0
for (const dispatcher of this[kClients].values()) {
ret += dispatcher[kRunning]
}
return ret
}
[kDispatch] (opts, handler) {
let origin
if (opts.origin && (typeof opts.origin === 'string' || opts.origin instanceof URL)) {
origin = String(opts.origin)
} else {
throw new InvalidArgumentError('opts.origin must be a non-empty string or URL.')
}
const allowH2 = opts.allowH2 ?? this[kOptions].allowH2
const key = allowH2 === false ? `${origin}#http1-only` : origin
if (this[kOrigins].size >= this[kOptions].maxOrigins && !this[kOrigins].has(origin)) {
throw new MaxOriginsReachedError()
}
let dispatcher = this[kClients].get(key)
if (!dispatcher) {
dispatcher = this[kFactory](opts.origin, allowH2 === false
? { ...this[kOptions], allowH2: false }
: this[kOptions])
const closeClientIfUnused = () => {
if (this[kClients].get(key) !== dispatcher) {
return
}
// A GOAWAY detaches the HTTP/2 session before requeued requests are
// dispatched on a replacement connection. At that point the pool has
// no connected clients and is not busy, but it still has pending work.
// Closing it here lets the replacement Client finish those requests
// and then destroys that new connection with ClientDestroyedError.
if (dispatcher[kConnected] > 0 || dispatcher[kBusy] || dispatcher[kPending] > 0) {
return
}
this[kClients].delete(key)
if (!dispatcher.destroyed) {
dispatcher.close()
}
let hasOrigin = false
for (const k of this[kClients].keys()) {
if (k === origin || k === `${origin}#http1-only`) {
hasOrigin = true
break
}
}
if (!hasOrigin) {
this[kOrigins].delete(origin)
}
}
dispatcher
.on('drain', this[kOnDrain])
.on('connect', this[kOnConnect])
.on('disconnect', (origin, targets, err) => {
closeClientIfUnused()
this[kOnDisconnect](origin, targets, err)
})
.on('connectionError', (origin, targets, err) => {
closeClientIfUnused()
this[kOnConnectionError](origin, targets, err)
})
this[kClients].set(key, dispatcher)
this[kOrigins].add(origin)
}
return dispatcher.dispatch(opts, handler)
}
[kClose] () {
const closePromises = []
for (const dispatcher of this[kClients].values()) {
closePromises.push(dispatcher.close())
}
this[kClients].clear()
return Promise.all(closePromises)
}
[kDestroy] (err) {
const destroyPromises = []
for (const dispatcher of this[kClients].values()) {
destroyPromises.push(dispatcher.destroy(err))
}
this[kClients].clear()
return Promise.all(destroyPromises)
}
get stats () {
const allClientStats = {}
for (const dispatcher of this[kClients].values()) {
if (dispatcher.stats) {
allClientStats[dispatcher[kUrl].origin] = dispatcher.stats
}
}
return allClientStats
}
}
module.exports = Agent
+217
View File
@@ -0,0 +1,217 @@
'use strict'
const {
BalancedPoolMissingUpstreamError,
InvalidArgumentError
} = require('../core/errors')
const {
PoolBase,
kClients,
kNeedDrain,
kAddClient,
kRemoveClient,
kGetDispatcher
} = require('./pool-base')
const Pool = require('./pool')
const { kOriginless, kUrl } = require('../core/symbols')
const util = require('../core/util')
const kFactory = Symbol('factory')
const kOptions = Symbol('options')
const kGreatestCommonDivisor = Symbol('kGreatestCommonDivisor')
const kCurrentWeight = Symbol('kCurrentWeight')
const kIndex = Symbol('kIndex')
const kWeight = Symbol('kWeight')
const kMaxWeightPerServer = Symbol('kMaxWeightPerServer')
const kErrorPenalty = Symbol('kErrorPenalty')
/**
* Calculate the greatest common divisor of two numbers by
* using the Euclidean algorithm.
*
* @param {number} a
* @param {number} b
* @returns {number}
*/
function getGreatestCommonDivisor (a, b) {
if (a === 0) return b
while (b !== 0) {
const t = b
b = a % b
a = t
}
return a
}
function defaultFactory (origin, opts) {
return new Pool(origin, opts)
}
class BalancedPool extends PoolBase {
constructor (upstreams = [], { factory = defaultFactory, connect, tls, ...opts } = {}) {
if (typeof factory !== 'function') {
throw new InvalidArgumentError('factory must be a function.')
}
super(opts)
this[kOriginless] = true
if (connect && typeof connect !== 'function') connect = { ...connect }
if (tls && typeof tls !== 'function') tls = { ...tls }
this[kOptions] = { ...util.deepClone(opts), connect, tls }
this[kIndex] = -1
this[kCurrentWeight] = 0
this[kMaxWeightPerServer] = this[kOptions].maxWeightPerServer || 100
this[kErrorPenalty] = this[kOptions].errorPenalty || 15
if (!Array.isArray(upstreams)) {
upstreams = [upstreams]
}
this[kFactory] = factory
for (const upstream of upstreams) {
this.addUpstream(upstream)
}
this._updateBalancedPoolStats()
}
addUpstream (upstream) {
const upstreamOrigin = util.parseOrigin(upstream).origin
if (this[kClients].find((pool) => (
pool[kUrl].origin === upstreamOrigin &&
pool.closed !== true &&
pool.destroyed !== true
))) {
return this
}
const pool = this[kFactory](upstreamOrigin, this[kOptions])
this[kAddClient](pool)
pool.on('connect', () => {
pool[kWeight] = Math.min(this[kMaxWeightPerServer], pool[kWeight] + this[kErrorPenalty])
})
pool.on('connectionError', () => {
pool[kWeight] = Math.max(1, pool[kWeight] - this[kErrorPenalty])
this._updateBalancedPoolStats()
})
pool.on('disconnect', (...args) => {
const err = args[2]
if (err && err.code === 'UND_ERR_SOCKET') {
// decrease the weight of the pool.
pool[kWeight] = Math.max(1, pool[kWeight] - this[kErrorPenalty])
this._updateBalancedPoolStats()
}
})
for (const client of this[kClients]) {
client[kWeight] = this[kMaxWeightPerServer]
}
this._updateBalancedPoolStats()
return this
}
_updateBalancedPoolStats () {
let result = 0
for (let i = 0; i < this[kClients].length; i++) {
result = getGreatestCommonDivisor(this[kClients][i][kWeight], result)
}
this[kGreatestCommonDivisor] = result
}
removeUpstream (upstream) {
const upstreamOrigin = util.parseOrigin(upstream).origin
const pool = this[kClients].find((pool) => (
pool[kUrl].origin === upstreamOrigin &&
pool.closed !== true &&
pool.destroyed !== true
))
if (pool) {
this[kRemoveClient](pool)
}
return this
}
getUpstream (upstream) {
const upstreamOrigin = util.parseOrigin(upstream).origin
return this[kClients].find((pool) => (
pool[kUrl].origin === upstreamOrigin &&
pool.closed !== true &&
pool.destroyed !== true
))
}
get upstreams () {
return this[kClients]
.filter(dispatcher => dispatcher.closed !== true && dispatcher.destroyed !== true)
.map((p) => p[kUrl].origin)
}
[kGetDispatcher] () {
// We validate that pools is greater than 0,
// otherwise we would have to wait until an upstream
// is added, which might never happen.
if (this[kClients].length === 0) {
throw new BalancedPoolMissingUpstreamError()
}
let counter = 0
let maxWeightIndex = -1
while (counter++ < this[kClients].length) {
this[kIndex] = (this[kIndex] + 1) % this[kClients].length
const pool = this[kClients][this[kIndex]]
// decrease the current weight every `this[kClients].length`.
if (this[kIndex] === 0) {
// Set the current weight to the next lower weight.
this[kCurrentWeight] = this[kCurrentWeight] - this[kGreatestCommonDivisor]
if (this[kCurrentWeight] <= 0) {
this[kCurrentWeight] = this[kMaxWeightPerServer]
}
}
// Skip unavailable pools after updating the current weight for this cycle.
if (
pool[kNeedDrain] ||
pool.closed === true ||
pool.destroyed === true
) {
continue
}
// Track the best fallback if no pool matches the current weight.
if (maxWeightIndex === -1 || pool[kWeight] > this[kClients][maxWeightIndex][kWeight]) {
maxWeightIndex = this[kIndex]
}
if (pool[kWeight] >= this[kCurrentWeight]) {
return pool
}
}
if (maxWeightIndex === -1) {
return
}
this[kCurrentWeight] = this[kClients][maxWeightIndex][kWeight]
this[kIndex] = maxWeightIndex
return this[kClients][maxWeightIndex]
}
}
module.exports = BalancedPool
+1808
View File
File diff suppressed because it is too large. Load diff
+1825
View File
File diff suppressed because it is too large. Load diff
+746
View File
@@ -0,0 +1,746 @@
'use strict'
const assert = require('node:assert')
const net = require('node:net')
const http = require('node:http')
const util = require('../core/util.js')
const { ClientStats } = require('../util/stats.js')
const { channels } = require('../core/diagnostics.js')
const Request = require('../core/request.js')
const DispatcherBase = require('./dispatcher-base')
const {
InvalidArgumentError,
InformationalError,
ClientDestroyedError
} = require('../core/errors.js')
const buildConnector = require('../core/connect.js')
const {
kUrl,
kServerName,
kClient,
kBusy,
kConnect,
kResuming,
kRunning,
kPending,
kSize,
kQueue,
kConnected,
kConnecting,
kNeedDrain,
kKeepAliveDefaultTimeout,
kHostHeader,
kPendingIdx,
kRunningIdx,
kError,
kPipelining,
kKeepAliveTimeoutValue,
kMaxHeadersSize,
kKeepAliveMaxTimeout,
kKeepAliveTimeoutThreshold,
kHeadersTimeout,
kBodyTimeout,
kStrictContentLength,
kConnector,
kMaxRequests,
kCounter,
kClose,
kDestroy,
kDispatch,
kLocalAddress,
kMaxResponseSize,
kOnError,
kHTTPContext,
kMaxConcurrentStreams,
kHostAuthority,
kResume,
kHTTP2Options
} = require('../core/symbols.js')
const connectH1 = require('./client-h1.js')
const connectH2 = require('./client-h2.js')
const kClosedResolve = Symbol('kClosedResolve')
const getDefaultNodeMaxHeaderSize = http &&
http.maxHeaderSize &&
Number.isInteger(http.maxHeaderSize) &&
http.maxHeaderSize > 0
? () => http.maxHeaderSize
: () => { throw new InvalidArgumentError('http module not available or http.maxHeaderSize invalid') }
const noop = () => { }
function getPipelining (client) {
return client[kPipelining] ?? client[kHTTPContext]?.defaultPipelining ?? 1
}
let h2NamespaceOptsWarning = false
function emitH2OptionsNamespaceWarning (optName) {
if (h2NamespaceOptsWarning === true) return
process.emitWarning(`Use h2Options.${optName} instead. ${optName} for H2 will be deprecated in future major.`, {
code: 'UNDICI-H2-OPTIONS'
})
h2NamespaceOptsWarning = true
}
// Protocol-aware dispatch ceiling. h1 RFC7230 pipelining is unrelated to h2
// stream multiplexing — over h2 the ceiling is the (server-confirmed)
// maxConcurrentStreams. Before a context is attached we use the h1
// pipelining factor; once h2 attaches the queued requests can drain in
// one batch up to maxConcurrentStreams.
function getMaxConcurrent (client) {
if (client[kHTTPContext]?.version === 'h2') {
return client[kMaxConcurrentStreams]
}
return getPipelining(client)
}
/**
* @type {import('../../types/client.js').default}
*/
class Client extends DispatcherBase {
/**
*
* @param {string|URL} url
* @param {import('../../types/client.js').Client.Options} options
*/
constructor (url, {
maxHeaderSize,
headersTimeout,
socketTimeout,
requestTimeout,
connectTimeout,
bodyTimeout,
idleTimeout,
keepAlive,
keepAliveTimeout,
maxKeepAliveTimeout,
keepAliveMaxTimeout,
keepAliveTimeoutThreshold,
socketPath,
pipelining,
tls,
strictContentLength,
maxCachedSessions,
connect,
maxRequestsPerClient,
localAddress,
maxResponseSize,
autoSelectFamily,
autoSelectFamilyAttemptTimeout,
// h2
maxConcurrentStreams,
allowH2,
useH2c,
initialWindowSize,
connectionWindowSize,
pingInterval,
webSocket,
h2Options,
eventSource
} = {}) {
if (keepAlive !== undefined) {
throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
}
if (socketTimeout !== undefined) {
throw new InvalidArgumentError('unsupported socketTimeout, use headersTimeout & bodyTimeout instead')
}
if (requestTimeout !== undefined) {
throw new InvalidArgumentError('unsupported requestTimeout, use headersTimeout & bodyTimeout instead')
}
if (idleTimeout !== undefined) {
throw new InvalidArgumentError('unsupported idleTimeout, use keepAliveTimeout instead')
}
if (maxKeepAliveTimeout !== undefined) {
throw new InvalidArgumentError('unsupported maxKeepAliveTimeout, use keepAliveMaxTimeout instead')
}
if (maxHeaderSize != null) {
if (!Number.isInteger(maxHeaderSize) || maxHeaderSize < 1) {
throw new InvalidArgumentError('invalid maxHeaderSize')
}
} else {
// If maxHeaderSize is not provided, use the default value from the http module
// or if that is not available, throw an error.
maxHeaderSize = getDefaultNodeMaxHeaderSize()
}
if (socketPath != null && typeof socketPath !== 'string') {
throw new InvalidArgumentError('invalid socketPath')
}
if (connectTimeout != null && (!Number.isFinite(connectTimeout) || connectTimeout < 0)) {
throw new InvalidArgumentError('invalid connectTimeout')
}
if (keepAliveTimeout != null && (!Number.isFinite(keepAliveTimeout) || keepAliveTimeout <= 0)) {
throw new InvalidArgumentError('invalid keepAliveTimeout')
}
if (keepAliveMaxTimeout != null && (!Number.isFinite(keepAliveMaxTimeout) || keepAliveMaxTimeout <= 0)) {
throw new InvalidArgumentError('invalid keepAliveMaxTimeout')
}
if (keepAliveTimeoutThreshold != null && !Number.isFinite(keepAliveTimeoutThreshold)) {
throw new InvalidArgumentError('invalid keepAliveTimeoutThreshold')
}
if (headersTimeout != null && (!Number.isInteger(headersTimeout) || headersTimeout < 0)) {
throw new InvalidArgumentError('headersTimeout must be a positive integer or zero')
}
if (bodyTimeout != null && (!Number.isInteger(bodyTimeout) || bodyTimeout < 0)) {
throw new InvalidArgumentError('bodyTimeout must be a positive integer or zero')
}
if (connect != null && typeof connect !== 'function' && typeof connect !== 'object') {
throw new InvalidArgumentError('connect must be a function or an object')
}
if (maxRequestsPerClient != null && (!Number.isInteger(maxRequestsPerClient) || maxRequestsPerClient < 0)) {
throw new InvalidArgumentError('maxRequestsPerClient must be a positive number')
}
if (localAddress != null && (typeof localAddress !== 'string' || net.isIP(localAddress) === 0)) {
throw new InvalidArgumentError('localAddress must be valid string IP address')
}
if (maxResponseSize != null && (!Number.isInteger(maxResponseSize) || maxResponseSize < -1)) {
throw new InvalidArgumentError('maxResponseSize must be a positive number')
}
if (
autoSelectFamilyAttemptTimeout != null &&
(!Number.isInteger(autoSelectFamilyAttemptTimeout) || autoSelectFamilyAttemptTimeout < -1)
) {
throw new InvalidArgumentError('autoSelectFamilyAttemptTimeout must be a positive number')
}
// h2
if (allowH2 != null && typeof allowH2 !== 'boolean') {
throw new InvalidArgumentError('allowH2 must be a valid boolean value')
}
// We validate only if allowH2 is enabled or null (enabled by default)
if (allowH2 !== false) {
// Prioritise new h2Options object, otherwise fallback to prior configuration options
if (h2Options != null) {
if (h2Options.useH2c != null && typeof h2Options.useH2c !== 'boolean') {
throw new InvalidArgumentError('h2Options.useH2c must be a valid boolean value')
}
if (h2Options.settings?.initialWindowSize != null && (!Number.isInteger(h2Options.settings.initialWindowSize) || h2Options.settings.initialWindowSize < 1)) {
throw new InvalidArgumentError('h2Options.settings.initialWindowSize must be a positive integer, greater than 0')
}
if (h2Options.maxConcurrentStreams != null && (!Number.isInteger(h2Options.maxConcurrentStreams) || h2Options.maxConcurrentStreams < 1)) {
throw new InvalidArgumentError('h2Options.maxConcurrentStreams must be a positive integer, greater than 0')
}
if (h2Options.connectionWindowSize != null && (!Number.isInteger(h2Options.connectionWindowSize) || h2Options.connectionWindowSize < 1)) {
throw new InvalidArgumentError('h2Options.connectionWindowSize must be a positive integer, greater than 0')
}
if (h2Options.pingInterval != null && (typeof h2Options.pingInterval !== 'number' || !Number.isInteger(h2Options.pingInterval) || h2Options.pingInterval < 0)) {
throw new InvalidArgumentError('h2Options.pingInterval must be a positive integer, greater or equal to 0')
}
} else {
if (useH2c != null && typeof useH2c !== 'boolean') {
emitH2OptionsNamespaceWarning('useH2c')
throw new InvalidArgumentError('useH2c must be a valid boolean value')
}
if (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== 'number' || maxConcurrentStreams < 1)) {
emitH2OptionsNamespaceWarning('maxConcurrentStreams')
throw new InvalidArgumentError('maxConcurrentStreams must be a positive integer, greater than 0')
}
if (initialWindowSize != null && (!Number.isInteger(initialWindowSize) || initialWindowSize < 1)) {
emitH2OptionsNamespaceWarning('initialWindowSize')
throw new InvalidArgumentError('initialWindowSize must be a positive integer, greater than 0')
}
if (connectionWindowSize != null && (!Number.isInteger(connectionWindowSize) || connectionWindowSize < 1)) {
emitH2OptionsNamespaceWarning('connectionWindowSize')
throw new InvalidArgumentError('connectionWindowSize must be a positive integer, greater than 0')
}
if (pingInterval != null && (typeof pingInterval !== 'number' || !Number.isInteger(pingInterval) || pingInterval < 0)) {
emitH2OptionsNamespaceWarning('pingInterval')
throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0')
}
}
}
super({ webSocket, eventSource })
if (typeof connect !== 'function') {
connect = buildConnector({
...tls,
maxCachedSessions,
allowH2,
socketPath,
useH2c: h2Options?.useH2c ?? useH2c,
timeout: connectTimeout,
...(typeof autoSelectFamily === 'boolean' ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined),
...connect
})
} else {
const customConnect = connect
connect = (opts, callback) => customConnect({
...opts,
...(socketPath != null ? { socketPath } : null),
...(allowH2 != null ? { allowH2 } : null)
}, callback)
}
this[kUrl] = util.parseOrigin(url)
this[kHostAuthority] = `${this[kUrl].hostname}${this[kUrl].port ? `:${this[kUrl].port}` : ''}`
this[kConnector] = connect
this[kPipelining] = pipelining != null ? pipelining : 1
this[kMaxHeadersSize] = maxHeaderSize
this[kKeepAliveDefaultTimeout] = keepAliveTimeout == null ? 4e3 : keepAliveTimeout
this[kKeepAliveMaxTimeout] = keepAliveMaxTimeout == null ? 600e3 : keepAliveMaxTimeout
this[kKeepAliveTimeoutThreshold] = keepAliveTimeoutThreshold == null ? 2e3 : keepAliveTimeoutThreshold
this[kKeepAliveTimeoutValue] = this[kKeepAliveDefaultTimeout]
this[kServerName] = null
this[kLocalAddress] = localAddress != null ? localAddress : null
this[kResuming] = 0 // 0, idle, 1, scheduled, 2 resuming
this[kNeedDrain] = 0 // 0, idle, 1, scheduled, 2 resuming
this[kHostHeader] = `host: ${this[kHostAuthority]}\r\n`
this[kBodyTimeout] = bodyTimeout != null ? bodyTimeout : 300e3
this[kHeadersTimeout] = headersTimeout != null ? headersTimeout : 300e3
this[kStrictContentLength] = strictContentLength == null ? true : strictContentLength
this[kMaxRequests] = maxRequestsPerClient
this[kClosedResolve] = null
this[kMaxResponseSize] = maxResponseSize > -1 ? maxResponseSize : -1
this[kHTTPContext] = null
// h2
this[kHTTP2Options] = {
pingInterval: h2Options?.pingInterval ?? pingInterval ?? 60e3,
connectionWindowSize: h2Options?.connectionWindowSize ?? connectionWindowSize ?? 524288,
maxConcurrentStreams: h2Options?.maxConcurrentStreams ?? maxConcurrentStreams ?? 100, // Max peerConcurrentStreams for a Node h2 server
sessionOptions: {
// HTTP/2 window sizes are set to higher defaults than Node.js core for better performance:
// - initialWindowSize: 262144 (256KB) vs Node.js default 65535 (64KB - 1)
// Allows more data to be sent before requiring acknowledgment, improving throughput
// especially on high-latency networks. This matches common production HTTP/2 servers.
// - connectionWindowSize: 524288 (512KB) vs Node.js default (none set)
// Provides better flow control for the entire connection across multiple streams.
initialWindowSize: h2Options?.initialWindowSize ?? initialWindowSize ?? 262144
}
}
// kQueue is built up of 3 sections separated by
// the kRunningIdx and kPendingIdx indices.
// | complete | running | pending |
// ^ kRunningIdx ^ kPendingIdx ^ kQueue.length
// kRunningIdx points to the first running element.
// kPendingIdx points to the first pending element.
// This implements a fast queue with an amortized
// time of O(1).
this[kQueue] = []
this[kRunningIdx] = 0
this[kPendingIdx] = 0
this[kResume] = (sync) => resume(this, sync)
this[kOnError] = (err) => onError(this, err)
}
get pipelining () {
return this[kPipelining]
}
set pipelining (value) {
this[kPipelining] = value
this[kResume](true)
}
get stats () {
return new ClientStats(this)
}
get [kPending] () {
return this[kQueue].length - this[kPendingIdx]
}
get [kRunning] () {
return this[kPendingIdx] - this[kRunningIdx]
}
get [kSize] () {
return this[kQueue].length - this[kRunningIdx]
}
get [kConnected] () {
return !!this[kHTTPContext] && !this[kConnecting] && !this[kHTTPContext].destroyed
}
get [kBusy] () {
// The `kPending > 0` check below is the gate Pool uses to decide whether
// to spin up an additional Client. For h1 that fan-out is correct —
// each socket only handles one pipelined request at a time. Once an h2
// context is attached we want concurrent dispatches to multiplex onto
// the shared session, so suppress that signal in the h2 case.
const allowsMux = this[kHTTPContext]?.version === 'h2'
return Boolean(
this[kHTTPContext]?.busy(null) ||
(this[kSize] >= (getMaxConcurrent(this) || 1)) ||
(this[kPending] > 0 && !allowsMux)
)
}
[kConnect] (cb) {
connect(this)
this.once('connect', cb)
}
[kDispatch] (opts, handler) {
const request = new Request(this[kUrl].origin, opts, handler)
this[kQueue].push(request)
if (this[kResuming]) {
// Do nothing.
} else if (util.bodyLength(request.body) == null && util.isIterable(request.body)) {
// Wait a tick in case stream/iterator is ended in the same tick.
this[kResuming] = 1
queueMicrotask(() => resume(this))
} else {
this[kResume](true)
}
if (this[kResuming] && this[kNeedDrain] !== 2 && this[kBusy]) {
this[kNeedDrain] = 2
}
return this[kNeedDrain] < 2
}
[kClose] () {
// TODO: for H2 we need to gracefully flush the remaining enqueued
// request and close each stream.
return new Promise((resolve) => {
if (this[kSize]) {
this[kClosedResolve] = resolve
} else {
resolve(null)
}
})
}
[kDestroy] (err) {
return new Promise((resolve) => {
const requests = this[kQueue].splice(this[kPendingIdx])
for (let i = 0; i < requests.length; i++) {
const request = requests[i]
if (request != null) {
util.errorRequest(this, request, err)
}
}
const callback = () => {
if (this[kClosedResolve]) {
// TODO (fix): Should we error here with ClientDestroyedError?
this[kClosedResolve]()
this[kClosedResolve] = null
}
resolve(null)
}
if (this[kHTTPContext]) {
this[kHTTPContext].destroy(err, callback)
this[kHTTPContext] = null
} else {
queueMicrotask(callback)
}
this[kResume]()
})
}
}
function onError (client, err) {
if (
client[kRunning] === 0 &&
err.code !== 'UND_ERR_INFO' &&
err.code !== 'UND_ERR_SOCKET'
) {
// Error is not caused by running request and not a recoverable
// socket error.
assert(client[kPendingIdx] === client[kRunningIdx])
const requests = client[kQueue].splice(client[kRunningIdx])
for (let i = 0; i < requests.length; i++) {
const request = requests[i]
if (request != null) {
util.errorRequest(client, request, err)
}
}
assert(client[kSize] === 0)
}
}
/**
* @param {Client} client
* @returns {void}
*/
function connect (client) {
assert(!client[kConnecting])
assert(!client[kHTTPContext])
let { host, hostname, protocol, port } = client[kUrl]
// Resolve ipv6
if (hostname[0] === '[') {
const idx = hostname.indexOf(']')
assert(idx !== -1)
const ip = hostname.substring(1, idx)
assert(net.isIPv6(ip))
hostname = ip
}
client[kConnecting] = true
if (channels.beforeConnect.hasSubscribers) {
channels.beforeConnect.publish({
connectParams: {
host,
hostname,
protocol,
port,
version: client[kHTTPContext]?.version,
servername: client[kServerName],
localAddress: client[kLocalAddress]
},
connector: client[kConnector]
})
}
try {
client[kConnector]({
host,
hostname,
protocol,
port,
servername: client[kServerName],
localAddress: client[kLocalAddress]
}, (err, socket) => {
if (err) {
handleConnectError(client, err, { host, hostname, protocol, port })
client[kResume]()
return
}
if (client.destroyed) {
util.destroy(socket.on('error', noop), new ClientDestroyedError())
client[kResume]()
return
}
assert(socket)
try {
client[kHTTPContext] = socket.alpnProtocol === 'h2'
? connectH2(client, socket)
: connectH1(client, socket)
} catch (err) {
socket.destroy().on('error', noop)
handleConnectError(client, err, { host, hostname, protocol, port })
client[kResume]()
return
}
client[kConnecting] = false
socket[kCounter] = 0
socket[kMaxRequests] = client[kMaxRequests]
socket[kClient] = client
socket[kError] = null
if (channels.connected.hasSubscribers) {
channels.connected.publish({
connectParams: {
host,
hostname,
protocol,
port,
version: client[kHTTPContext]?.version,
servername: client[kServerName],
localAddress: client[kLocalAddress]
},
connector: client[kConnector],
socket
})
}
client.emit('connect', client[kUrl], [client])
client[kResume]()
})
} catch (err) {
handleConnectError(client, err, { host, hostname, protocol, port })
client[kResume]()
}
}
function handleConnectError (client, err, { host, hostname, protocol, port }) {
if (client.destroyed) {
return
}
client[kConnecting] = false
if (channels.connectError.hasSubscribers) {
channels.connectError.publish({
connectParams: {
host,
hostname,
protocol,
port,
version: client[kHTTPContext]?.version,
servername: client[kServerName],
localAddress: client[kLocalAddress]
},
connector: client[kConnector],
error: err
})
}
if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
const running = client[kQueue].splice(client[kRunningIdx], client[kRunning])
client[kPendingIdx] = client[kRunningIdx]
for (let i = 0; i < running.length; i++) {
util.errorRequest(client, running[i], err)
}
while (client[kPending] > 0 && client[kQueue][client[kPendingIdx]].servername === client[kServerName]) {
const request = client[kQueue].splice(client[kPendingIdx], 1)[0]
util.errorRequest(client, request, err)
}
} else {
onError(client, err)
}
client.emit('connectionError', client[kUrl], [client], err)
}
function emitDrain (client) {
client[kNeedDrain] = 0
client.emit('drain', client[kUrl], [client])
}
function resume (client, sync) {
if (client[kResuming] === 2) {
return
}
client[kResuming] = 2
_resume(client, sync)
client[kResuming] = 0
if (client[kRunningIdx] > 256) {
client[kQueue].splice(0, client[kRunningIdx])
client[kPendingIdx] -= client[kRunningIdx]
client[kRunningIdx] = 0
}
}
function _resume (client, sync) {
while (true) {
if (client.destroyed) {
assert(client[kPending] === 0)
return
}
if (client[kClosedResolve] && !client[kSize]) {
client[kClosedResolve]()
client[kClosedResolve] = null
return
}
if (client[kHTTPContext]) {
client[kHTTPContext].resume()
}
if (client[kBusy]) {
client[kNeedDrain] = 2
} else if (client[kNeedDrain] === 2) {
if (sync) {
client[kNeedDrain] = 1
queueMicrotask(() => emitDrain(client))
} else {
emitDrain(client)
}
continue
}
if (client[kPending] === 0) {
return
}
if (client[kRunning] >= (getMaxConcurrent(client) || 1)) {
return
}
const request = client[kQueue][client[kPendingIdx]]
if (request === null) {
return
}
if (request.aborted) {
client[kQueue].splice(client[kPendingIdx], 1)
continue
}
if (client[kUrl].protocol === 'https:' && client[kServerName] !== request.servername) {
if (client[kRunning] > 0) {
return
}
client[kServerName] = request.servername
client[kHTTPContext]?.destroy(new InformationalError('servername changed'), () => {
client[kHTTPContext] = null
resume(client)
})
}
if (client[kConnecting]) {
return
}
if (!client[kHTTPContext]) {
client[kServerName] = request.servername
connect(client)
return
}
if (client[kHTTPContext].destroyed) {
return
}
if (client[kHTTPContext].busy(request)) {
return
}
if (!request.aborted && client[kHTTPContext].write(request)) {
client[kPendingIdx]++
} else {
client[kQueue].splice(client[kPendingIdx], 1)
}
}
}
module.exports = Client
+197
View File
@@ -0,0 +1,197 @@
'use strict'
const buffer = require('node:buffer')
const Dispatcher = require('./dispatcher')
const {
ClientDestroyedError,
ClientClosedError,
InvalidArgumentError
} = require('../core/errors')
const { kDestroy, kClose, kClosed, kDestroyed, kDispatch } = require('../core/symbols')
const kOnDestroyed = Symbol('onDestroyed')
const kOnClosed = Symbol('onClosed')
const kWebSocketOptions = Symbol('webSocketOptions')
const kEventSourceOptions = Symbol('eventSourceOptions')
class DispatcherBase extends Dispatcher {
/** @type {boolean} */
[kDestroyed] = false;
/** @type {Array<Function|null} */
[kOnDestroyed] = null;
/** @type {boolean} */
[kClosed] = false;
/** @type {Array<Function>|null} */
[kOnClosed] = null
/**
* @param {import('../../types/dispatcher').DispatcherOptions} [opts]
*/
constructor (opts) {
super()
this[kWebSocketOptions] = opts?.webSocket ?? {}
this[kEventSourceOptions] = opts?.eventSource ?? {}
}
/**
* @returns {import('../../types/client').Client.WebSocketOptions}
*/
get webSocketOptions () {
return {
maxFragments: this[kWebSocketOptions].maxFragments ?? 131072,
maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024 // 128 MB default
}
}
/**
* @returns {import('../../types/client').Client.EventSourceOptions}
*/
get eventSourceOptions () {
return {
maxEventSize: this[kEventSourceOptions].maxEventSize ?? buffer.kStringMaxLength
}
}
/** @returns {boolean} */
get destroyed () {
return this[kDestroyed]
}
/** @returns {boolean} */
get closed () {
return this[kClosed]
}
close (callback) {
if (callback === undefined) {
return new Promise((resolve, reject) => {
this.close((err, data) => {
return err ? reject(err) : resolve(data)
})
})
}
if (typeof callback !== 'function') {
throw new InvalidArgumentError('invalid callback')
}
if (this[kDestroyed]) {
const err = new ClientDestroyedError()
queueMicrotask(() => callback(err, null))
return
}
if (this[kClosed]) {
if (this[kOnClosed]) {
this[kOnClosed].push(callback)
} else {
queueMicrotask(() => callback(null, null))
}
return
}
this[kClosed] = true
this[kOnClosed] ??= []
this[kOnClosed].push(callback)
const onClosed = () => {
const callbacks = this[kOnClosed]
this[kOnClosed] = null
for (let i = 0; i < callbacks.length; i++) {
callbacks[i](null, null)
}
}
// Should not error.
this[kClose]()
.then(() => this.destroy())
.then(() => queueMicrotask(onClosed))
}
destroy (err, callback) {
if (typeof err === 'function') {
callback = err
err = null
}
if (callback === undefined) {
return new Promise((resolve, reject) => {
this.destroy(err, (err, data) => {
return err ? reject(err) : resolve(data)
})
})
}
if (typeof callback !== 'function') {
throw new InvalidArgumentError('invalid callback')
}
if (this[kDestroyed]) {
if (this[kOnDestroyed]) {
this[kOnDestroyed].push(callback)
} else {
queueMicrotask(() => callback(null, null))
}
return
}
if (!err) {
err = new ClientDestroyedError()
}
this[kDestroyed] = true
this[kOnDestroyed] ??= []
this[kOnDestroyed].push(callback)
const onDestroyed = () => {
const callbacks = this[kOnDestroyed]
this[kOnDestroyed] = null
for (let i = 0; i < callbacks.length; i++) {
callbacks[i](null, null)
}
}
// Should not error.
this[kDestroy](err)
.then(() => queueMicrotask(onDestroyed))
}
dispatch (opts, handler) {
if (!handler || typeof handler !== 'object') {
throw new InvalidArgumentError('handler must be an object')
}
try {
if (!opts || typeof opts !== 'object') {
throw new InvalidArgumentError('opts must be an object.')
}
if (opts.dispatcher) {
throw new InvalidArgumentError('opts.dispatcher is not supported by instance methods. Pass opts.dispatcher to the top-level undici functions or call the dispatcher instance method directly.')
}
if (this[kDestroyed] || this[kOnDestroyed]) {
throw new ClientDestroyedError()
}
if (this[kClosed]) {
throw new ClientClosedError()
}
return this[kDispatch](opts, handler)
} catch (err) {
if (typeof handler.onResponseError !== 'function') {
throw err
}
handler.onResponseError(null, err)
return false
}
}
}
module.exports = DispatcherBase
+58
View File
@@ -0,0 +1,58 @@
'use strict'
const EventEmitter = require('node:events')
const { kOriginless, kUrl } = require('../core/symbols')
class Dispatcher extends EventEmitter {
dispatch () {
throw new Error('not implemented')
}
close () {
throw new Error('not implemented')
}
destroy () {
throw new Error('not implemented')
}
compose (...args) {
// So we handle [interceptor1, interceptor2] or interceptor1, interceptor2, ...
const interceptors = Array.isArray(args[0]) ? args[0] : args
// null disables origin-dependent interceptors; undefined uses opts.origin.
const interceptorOrigin = this[kOriginless] === true
? null
: this[kUrl]?.origin
let dispatch = this.dispatch.bind(this)
for (const interceptor of interceptors) {
if (interceptor == null) {
continue
}
if (typeof interceptor !== 'function') {
throw new TypeError(`invalid interceptor, expected function received ${typeof interceptor}`)
}
dispatch = interceptor(dispatch, interceptorOrigin)
if (dispatch == null || typeof dispatch !== 'function' || dispatch.length !== 2) {
throw new TypeError('invalid interceptor')
}
}
const originalDispatch = dispatch
const self = this
dispatch = function (opts, handler) {
if (opts && typeof opts === 'object' && !opts.origin && self[kUrl]) {
opts = Object.assign({}, opts, { origin: self[kUrl].origin })
}
return originalDispatch(opts, handler)
}
return new Proxy(this, {
get: (target, key) => key === 'dispatch' ? dispatch : target[key]
})
}
}
module.exports = Dispatcher
+110
View File
@@ -0,0 +1,110 @@
'use strict'
const Dispatcher = require('./dispatcher')
const { InvalidArgumentError } = require('../core/errors')
const { toRawHeaders } = require('../core/util')
const { kOriginless, kUrl } = require('../core/symbols')
class LegacyHandlerWrapper {
#handler
constructor (handler) {
this.#handler = handler
}
onRequestStart (controller, context) {
this.#handler.onConnect?.((reason) => controller.abort(reason), context)
}
onRequestUpgrade (controller, statusCode, headers, socket) {
const rawHeaders = controller?.rawHeaders ?? toRawHeaders(headers ?? {})
this.#handler.onUpgrade?.(statusCode, rawHeaders, socket)
}
onResponseStart (controller, statusCode, headers, statusMessage) {
const rawHeaders = controller?.rawHeaders ?? toRawHeaders(headers ?? {})
if (this.#handler.onHeaders?.(statusCode, rawHeaders, () => controller.resume(), statusMessage) === false) {
controller.pause()
}
}
onResponseData (controller, chunk) {
if (this.#handler.onData?.(chunk) === false) {
controller.pause()
}
}
onResponseEnd (controller, trailers) {
const rawTrailers = controller?.rawTrailers ?? toRawHeaders(trailers ?? {})
this.#handler.onComplete?.(rawTrailers)
}
onResponseError (_controller, err) {
if (!this.#handler.onError) {
throw err
}
this.#handler.onError(err)
}
onBodySent (chunk) {
this.#handler.onBodySent?.(chunk)
}
onRequestSent () {
this.#handler.onRequestSent?.()
}
onResponseStarted () {
this.#handler.onResponseStarted?.()
}
}
class Dispatcher1Wrapper extends Dispatcher {
#dispatcher
constructor (dispatcher) {
super()
if (!dispatcher || typeof dispatcher.dispatch !== 'function') {
throw new InvalidArgumentError('Argument dispatcher must implement dispatch')
}
this.#dispatcher = dispatcher
this[kUrl] = dispatcher[kUrl]
this[kOriginless] = dispatcher[kOriginless]
}
static wrapHandler (handler) {
if (!handler || typeof handler !== 'object') {
throw new InvalidArgumentError('handler must be an object')
}
if (typeof handler.onRequestStart === 'function') {
return handler
}
return new LegacyHandlerWrapper(handler)
}
dispatch (opts, handler) {
// Legacy (v1) consumers do not support HTTP/2, so force HTTP/1.1.
// See https://github.com/nodejs/undici/issues/4989
if (opts.allowH2 !== false) {
opts = { ...opts, allowH2: false }
}
return this.#dispatcher.dispatch(opts, Dispatcher1Wrapper.wrapHandler(handler))
}
close (...args) {
return this.#dispatcher.close(...args)
}
destroy (...args) {
return this.#dispatcher.destroy(...args)
}
}
module.exports = Dispatcher1Wrapper
+190
View File
@@ -0,0 +1,190 @@
'use strict'
const DispatcherBase = require('./dispatcher-base')
const { kClose, kDestroy, kClosed, kDestroyed, kDispatch, kNoProxyAgent, kHttpProxyAgent, kHttpsProxyAgent } = require('../core/symbols')
const ProxyAgent = require('./proxy-agent')
const Agent = require('./agent')
const DEFAULT_PORTS = {
'http:': 80,
'https:': 443
}
class EnvHttpProxyAgent extends DispatcherBase {
#noProxyValue = null
#noProxyEntries = null
#opts = null
constructor (opts = {}) {
super(opts)
this.#opts = opts
const { httpProxy, httpsProxy, noProxy, ...agentOpts } = opts
this[kNoProxyAgent] = new Agent(agentOpts)
const HTTP_PROXY = httpProxy ?? process.env.http_proxy ?? process.env.HTTP_PROXY
if (HTTP_PROXY) {
this[kHttpProxyAgent] = new ProxyAgent({ ...agentOpts, uri: HTTP_PROXY })
} else {
this[kHttpProxyAgent] = this[kNoProxyAgent]
}
const HTTPS_PROXY = httpsProxy ?? process.env.https_proxy ?? process.env.HTTPS_PROXY
if (HTTPS_PROXY) {
this[kHttpsProxyAgent] = new ProxyAgent({ ...agentOpts, uri: HTTPS_PROXY })
} else {
this[kHttpsProxyAgent] = this[kHttpProxyAgent]
}
this.#parseNoProxy()
}
[kDispatch] (opts, handler) {
const url = new URL(opts.origin)
const agent = this.#getProxyAgentForUrl(url)
return agent.dispatch(opts, handler)
}
[kClose] () {
return Promise.all([
this[kNoProxyAgent].close(),
!this[kHttpProxyAgent][kClosed] && this[kHttpProxyAgent].close(),
!this[kHttpsProxyAgent][kClosed] && this[kHttpsProxyAgent].close()
])
}
[kDestroy] (err) {
return Promise.all([
this[kNoProxyAgent].destroy(err),
!this[kHttpProxyAgent][kDestroyed] && this[kHttpProxyAgent].destroy(err),
!this[kHttpsProxyAgent][kDestroyed] && this[kHttpsProxyAgent].destroy(err)
])
}
#getProxyAgentForUrl (url) {
let { protocol, host: hostname, port } = url
// Remove the port suffix (e.g. ":8080") and then strip surrounding
// brackets from IPv6 literals (e.g. "[::1]" -> "::1") so that the
// result matches the unbracketed form stored by #parseNoProxy.
hostname = hostname.replace(/:\d*$/, '').replace(/^\[(.+)\]$/, '$1').toLowerCase()
// Drop a trailing dot: it only marks the fully qualified form of a domain
// name ("example.com." and "example.com" are the same name, RFC 1034 root
// label). This runs on every dispatch, so it is a charCode check rather
// than a third regex. `length > 1` leaves the degenerate host "." alone.
if (hostname.length > 1 && hostname.charCodeAt(hostname.length - 1) === 46) {
hostname = hostname.slice(0, -1)
}
port = Number.parseInt(port, 10) || DEFAULT_PORTS[protocol] || 0
if (!this.#shouldProxy(hostname, port)) {
return this[kNoProxyAgent]
}
if (protocol === 'https:') {
return this[kHttpsProxyAgent]
}
return this[kHttpProxyAgent]
}
#shouldProxy (hostname, port) {
if (this.#noProxyChanged) {
this.#parseNoProxy()
}
if (this.#noProxyEntries.length === 0) {
return true // Always proxy if NO_PROXY is not set or empty.
}
for (let i = 0; i < this.#noProxyEntries.length; i++) {
const entry = this.#noProxyEntries[i]
// A bare `*` entry matches all hosts regardless of its position or the
// surrounding whitespace (e.g. ` * ` or `none.invalid,*`). If a port is
// attached (`*:80`) it only bypasses that port.
if (entry.hostname === '*') {
if (entry.port && entry.port !== port) {
continue
}
return false // Never proxy if a wildcard entry is present.
}
if (entry.port && entry.port !== port) {
continue // Skip if ports don't match.
}
// Don't proxy if the hostname is equal with the no_proxy host. A
// `*.example.com` wildcard matches subdomains only, not the apex
// `example.com`, so exact matches are skipped for wildcard entries.
if (!entry.wildcard && hostname === entry.hostname) {
return false
}
// Don't proxy if the hostname is the subdomain of the no_proxy host.
// Reference - https://github.com/denoland/deno/blob/6fbce91e40cc07fc6da74068e5cc56fdd40f7b4c/ext/fetch/proxy.rs#L485
if (hostname.slice(-(entry.hostname.length + 1)) === `.${entry.hostname}`) {
return false
}
}
return true
}
#parseNoProxy () {
const noProxyValue = this.#opts.noProxy ?? this.#noProxyEnv
const noProxySplit = noProxyValue.split(/[,\s]/)
const noProxyEntries = []
for (let i = 0; i < noProxySplit.length; i++) {
const entry = noProxySplit[i]
if (!entry) {
continue
}
// An IPv6 entry with a port must be bracketed: [::1]:443.
// A bare IPv6 address like ::1 contains colons that must not be
// confused with a host:port separator, so we handle it separately.
let hostname, port
const ipv6WithPort = entry.match(/^\[(.+)\]:(\d+)$/)
if (ipv6WithPort) {
hostname = ipv6WithPort[1]
port = Number.parseInt(ipv6WithPort[2], 10)
} else {
// Bracketed IPv6 without port, or plain hostname[:port], or bare IPv6.
// Strip optional brackets first.
const unbracketed = entry.replace(/^\[(.+)\]$/, '$1')
// A bare IPv6 address contains multiple colons; a hostname:port entry
// has exactly one colon followed by digits. Only attempt host:port
// splitting when that is unambiguously the case.
const colonCount = (unbracketed.match(/:/g) || []).length
const parsed = colonCount === 1 && unbracketed.match(/^(.+):(\d+)$/)
hostname = parsed ? parsed[1] : unbracketed
port = parsed ? Number.parseInt(parsed[2], 10) : 0
}
// A leading `*` marks a subdomain wildcard (`*.example.com`), distinct
// from a plain or leading-dot suffix (`example.com` / `.example.com`)
// which also matches the apex. `*.example.com` must only match
// subdomains, never the apex `example.com` itself.
const wildcard = entry.charCodeAt(0) === 42 /* '*' */
noProxyEntries.push({
// strip leading dot or asterisk with dot, and any trailing dot
hostname: hostname.replace(/^\*?\./, '').replace(/^(.+)\.$/, '$1').toLowerCase(),
port,
wildcard
})
}
this.#noProxyValue = noProxyValue
this.#noProxyEntries = noProxyEntries
}
get #noProxyChanged () {
if (this.#opts.noProxy !== undefined) {
return false
}
return this.#noProxyValue !== this.#noProxyEnv
}
get #noProxyEnv () {
return process.env.no_proxy ?? process.env.NO_PROXY ?? ''
}
}
module.exports = EnvHttpProxyAgent
+135
View File
@@ -0,0 +1,135 @@
'use strict'
// Extracted from node/lib/internal/fixed_queue.js
// Currently optimal queue size, tested on V8 6.0 - 6.6. Must be power of two.
const kSize = 2048
const kMask = kSize - 1
// The FixedQueue is implemented as a singly-linked list of fixed-size
// circular buffers. It looks something like this:
//
// head tail
// | |
// v v
// +-----------+ <-----\ +-----------+ <------\ +-----------+
// | [null] | \----- | next | \------- | next |
// +-----------+ +-----------+ +-----------+
// | item | <-- bottom | item | <-- bottom | undefined |
// | item | | item | | undefined |
// | item | | item | | undefined |
// | item | | item | | undefined |
// | item | | item | bottom --> | item |
// | item | | item | | item |
// | ... | | ... | | ... |
// | item | | item | | item |
// | item | | item | | item |
// | undefined | <-- top | item | | item |
// | undefined | | item | | item |
// | undefined | | undefined | <-- top top --> | undefined |
// +-----------+ +-----------+ +-----------+
//
// Or, if there is only one circular buffer, it looks something
// like either of these:
//
// head tail head tail
// | | | |
// v v v v
// +-----------+ +-----------+
// | [null] | | [null] |
// +-----------+ +-----------+
// | undefined | | item |
// | undefined | | item |
// | item | <-- bottom top --> | undefined |
// | item | | undefined |
// | undefined | <-- top bottom --> | item |
// | undefined | | item |
// +-----------+ +-----------+
//
// Adding a value means moving `top` forward by one, removing means
// moving `bottom` forward by one. After reaching the end, the queue
// wraps around.
//
// When `top === bottom` the current queue is empty and when
// `top + 1 === bottom` it's full. This wastes a single space of storage
// but allows much quicker checks.
/**
* @type {FixedCircularBuffer}
* @template T
*/
class FixedCircularBuffer {
/** @type {number} */
bottom = 0
/** @type {number} */
top = 0
/** @type {Array<T|undefined>} */
list = new Array(kSize).fill(undefined)
/** @type {T|null} */
next = null
/** @returns {boolean} */
isEmpty () {
return this.top === this.bottom
}
/** @returns {boolean} */
isFull () {
return ((this.top + 1) & kMask) === this.bottom
}
/**
* @param {T} data
* @returns {void}
*/
push (data) {
this.list[this.top] = data
this.top = (this.top + 1) & kMask
}
/** @returns {T|null} */
shift () {
const nextItem = this.list[this.bottom]
if (nextItem === undefined) { return null }
this.list[this.bottom] = undefined
this.bottom = (this.bottom + 1) & kMask
return nextItem
}
}
/**
* @template T
*/
module.exports = class FixedQueue {
constructor () {
/** @type {FixedCircularBuffer<T>} */
this.head = this.tail = new FixedCircularBuffer()
}
/** @returns {boolean} */
isEmpty () {
return this.head.isEmpty()
}
/** @param {T} data */
push (data) {
if (this.head.isFull()) {
// Head is full: Creates a new queue, sets the old queue's `.next` to it,
// and sets it as the new main queue.
this.head = this.head.next = new FixedCircularBuffer()
}
this.head.push(data)
}
/** @returns {T|null} */
shift () {
const tail = this.tail
const next = tail.shift()
if (tail.isEmpty() && tail.next !== null) {
// If there is another queue, it forms the new tail.
this.tail = tail.next
tail.next = null
}
return next
}
}
+51
View File
@@ -0,0 +1,51 @@
'use strict'
const { InvalidArgumentError } = require('../core/errors')
const Client = require('./client')
class H2CClient extends Client {
constructor (origin, clientOpts) {
if (typeof origin === 'string') {
origin = new URL(origin)
}
if (origin.protocol !== 'http:') {
throw new InvalidArgumentError(
'h2c-client: Only h2c protocol is supported'
)
}
const { maxConcurrentStreams, pipelining, ...opts } =
clientOpts ?? {}
const defaultMaxConcurrentStreams = maxConcurrentStreams ?? 100
let defaultPipelining = 100
if (
maxConcurrentStreams != null &&
(!Number.isInteger(maxConcurrentStreams) ||
maxConcurrentStreams < 1)
) {
throw new InvalidArgumentError('maxConcurrentStreams must be a positive integer, greater than 0')
}
if (pipelining != null && Number.isInteger(pipelining) && pipelining > 0) {
defaultPipelining = pipelining
}
if (defaultPipelining > defaultMaxConcurrentStreams) {
throw new InvalidArgumentError(
'h2c-client: pipelining cannot be greater than maxConcurrentStreams'
)
}
super(origin, {
...opts,
maxConcurrentStreams: defaultMaxConcurrentStreams,
pipelining: defaultPipelining,
allowH2: true,
useH2c: true
})
}
}
module.exports = H2CClient
+293
View File
@@ -0,0 +1,293 @@
'use strict'
const { PoolStats } = require('../util/stats.js')
const DispatcherBase = require('./dispatcher-base')
const FixedQueue = require('./fixed-queue')
const { kConnected, kSize, kRunning, kPending, kQueued, kBusy, kFree, kUrl, kClose, kDestroy, kDispatch } = require('../core/symbols')
const kClients = Symbol('clients')
const kNeedDrain = Symbol('needDrain')
const kQueue = Symbol('queue')
const kClosedResolve = Symbol('closed resolve')
const kOnDrain = Symbol('onDrain')
const kOnConnect = Symbol('onConnect')
const kOnDisconnect = Symbol('onDisconnect')
const kOnConnectionError = Symbol('onConnectionError')
const kOnClientBusy = Symbol('on client busy')
const kOnClientDrain = Symbol('on client drain')
const kDrainQueue = Symbol('drain queue')
const kGetDispatcher = Symbol('get dispatcher')
const kHasDispatcher = Symbol('has dispatcher')
const kAddClient = Symbol('add client')
const kRemoveClient = Symbol('remove client')
class PoolBase extends DispatcherBase {
[kQueue] = new FixedQueue();
[kQueued] = 0;
[kClients] = [];
[kNeedDrain] = false;
[kOnDrain] (client, origin, targets) {
if (client.closed || client.destroyed) {
return
}
const queue = this[kQueue]
let needDrain = false
this[kOnClientDrain](client)
while (!needDrain) {
const item = queue.shift()
if (!item) {
break
}
this[kQueued]--
needDrain = !client.dispatch(item.opts, item.handler)
}
client[kNeedDrain] = needDrain
if (needDrain) {
this[kOnClientBusy](client)
}
if (!needDrain && this[kNeedDrain]) {
this[kNeedDrain] = false
this.emit('drain', origin, [this, ...targets])
}
if (this[kClosedResolve] && queue.isEmpty()) {
const closeAll = []
for (let i = 0; i < this[kClients].length; i++) {
const client = this[kClients][i]
if (!client.destroyed) {
closeAll.push(client.close())
}
}
return Promise.all(closeAll)
.then(this[kClosedResolve])
}
}
[kOnClientBusy] () {}
[kOnClientDrain] () {}
[kDrainQueue] (origin, targets) {
const queue = this[kQueue]
let hasDispatcher = true
while (!queue.isEmpty()) {
const dispatcher = this[kGetDispatcher]()
if (!dispatcher) {
hasDispatcher = false
break
}
const item = queue.shift()
this[kQueued]--
if (!dispatcher.dispatch(item.opts, item.handler)) {
dispatcher[kNeedDrain] = true
this[kOnClientBusy](dispatcher)
hasDispatcher = this[kHasDispatcher]()
if (!hasDispatcher) {
break
}
}
}
if (hasDispatcher && this[kNeedDrain]) {
this[kNeedDrain] = false
this.emit('drain', origin, [this, ...targets])
}
if (this[kClosedResolve] && queue.isEmpty()) {
const closeAll = []
for (let i = 0; i < this[kClients].length; i++) {
const client = this[kClients][i]
if (!client.destroyed) {
closeAll.push(client.close())
}
}
return Promise.all(closeAll)
.then(this[kClosedResolve])
}
}
[kOnConnect] = (origin, targets) => {
this.emit('connect', origin, [this, ...targets])
};
[kOnDisconnect] = (origin, targets, err) => {
this.emit('disconnect', origin, [this, ...targets], err)
};
[kOnConnectionError] = (origin, targets, err) => {
this.emit('connectionError', origin, [this, ...targets], err)
}
get [kBusy] () {
return this[kNeedDrain]
}
get [kConnected] () {
let ret = 0
for (const { [kConnected]: connected } of this[kClients]) {
ret += connected
}
return ret
}
get [kFree] () {
let ret = 0
for (const { [kConnected]: connected, [kNeedDrain]: needDrain } of this[kClients]) {
ret += connected && !needDrain
}
return ret
}
get [kPending] () {
let ret = this[kQueued]
for (const { [kPending]: pending } of this[kClients]) {
ret += pending
}
return ret
}
get [kRunning] () {
let ret = 0
for (const { [kRunning]: running } of this[kClients]) {
ret += running
}
return ret
}
get [kSize] () {
let ret = this[kQueued]
for (const { [kSize]: size } of this[kClients]) {
ret += size
}
return ret
}
get stats () {
return new PoolStats(this)
}
[kClose] () {
if (this[kQueue].isEmpty()) {
const closeAll = []
for (let i = 0; i < this[kClients].length; i++) {
const client = this[kClients][i]
if (!client.destroyed) {
closeAll.push(client.close())
}
}
return Promise.all(closeAll)
} else {
return new Promise((resolve) => {
this[kClosedResolve] = resolve
})
}
}
[kDestroy] (err) {
while (true) {
const item = this[kQueue].shift()
if (!item) {
break
}
item.handler.onResponseError(null, err)
}
const destroyAll = new Array(this[kClients].length)
for (let i = 0; i < this[kClients].length; i++) {
destroyAll[i] = this[kClients][i].destroy(err)
}
return Promise.all(destroyAll)
}
[kDispatch] (opts, handler) {
const dispatcher = this[kGetDispatcher]()
if (!dispatcher) {
this[kNeedDrain] = true
this[kQueue].push({ opts, handler })
this[kQueued]++
} else if (!dispatcher.dispatch(opts, handler)) {
dispatcher[kNeedDrain] = true
this[kOnClientBusy](dispatcher)
this[kNeedDrain] = !this[kHasDispatcher]()
}
return !this[kNeedDrain]
}
[kHasDispatcher] () {
for (let i = 0; i < this[kClients].length; i++) {
const dispatcher = this[kClients][i]
if (
!dispatcher[kNeedDrain] &&
dispatcher.closed !== true &&
dispatcher.destroyed !== true
) {
return true
}
}
return false
}
[kAddClient] (client) {
client
.on('drain', this[kOnDrain].bind(this, client))
.on('connect', this[kOnConnect])
.on('disconnect', this[kOnDisconnect])
.on('connectionError', this[kOnConnectionError])
this[kClients].push(client)
if (this[kNeedDrain]) {
queueMicrotask(() => {
if (this[kNeedDrain] && !client[kNeedDrain]) {
this[kOnDrain](client, client[kUrl], [client, this])
}
})
}
return this
}
[kRemoveClient] (client) {
const idx = this[kClients].indexOf(client)
if (idx !== -1) {
this[kClients].splice(idx, 1)
}
client.close(() => {})
this[kNeedDrain] = !this[kClients].some(dispatcher => (
!dispatcher[kNeedDrain] &&
dispatcher.closed !== true &&
dispatcher.destroyed !== true
))
}
}
module.exports = {
PoolBase,
kClients,
kNeedDrain,
kAddClient,
kRemoveClient,
kDrainQueue,
kOnClientBusy,
kOnClientDrain,
kGetDispatcher,
kHasDispatcher
}
+226
View File
@@ -0,0 +1,226 @@
'use strict'
const {
PoolBase,
kClients,
kNeedDrain,
kAddClient,
kDrainQueue,
kOnClientBusy,
kOnClientDrain,
kGetDispatcher,
kHasDispatcher,
kRemoveClient
} = require('./pool-base')
const Client = require('./client')
const {
InvalidArgumentError
} = require('../core/errors')
const util = require('../core/util')
const { kConnecting, kHTTPContext, kUrl } = require('../core/symbols')
const buildConnector = require('../core/connect')
const kOptions = Symbol('options')
const kConnections = Symbol('connections')
const kFactory = Symbol('factory')
const kProtocol = Symbol('protocol')
const kProtocolProbe = Symbol('protocol probe')
function defaultFactory (origin, opts) {
return new Client(origin, opts)
}
function shouldCreateProtocolProbe (pool, dispatcher) {
return dispatcher instanceof Client &&
pool[kProtocol] !== 'h1' &&
(pool[kOptions].useH2c === true || (pool[kUrl].protocol === 'https:' && pool[kOptions].allowH2 !== false))
}
function createClient (pool) {
const dispatcher = pool[kFactory](pool[kUrl], pool[kOptions])
// HTTPS does not reveal whether the peer selected h1 or h2 until ALPN
// completes. While h2 is still possible, let one Client probe the protocol
// and keep later requests in the Pool queue instead of opening one TLS
// connection per request. A confirmed h1 connection disables this gate and
// restores the usual Pool fan-out.
if (shouldCreateProtocolProbe(pool, dispatcher)) {
pool[kProtocolProbe] = dispatcher
}
pool[kAddClient](dispatcher)
return dispatcher
}
class Pool extends PoolBase {
constructor (origin, {
connections,
factory = defaultFactory,
connect,
connectTimeout,
tls,
maxCachedSessions,
socketPath,
autoSelectFamily,
autoSelectFamilyAttemptTimeout,
allowH2,
useH2c,
clientTtl,
...options
} = {}) {
if (connections != null && (!Number.isFinite(connections) || connections < 0)) {
throw new InvalidArgumentError('invalid connections')
}
if (typeof factory !== 'function') {
throw new InvalidArgumentError('factory must be a function.')
}
if (connect != null && typeof connect !== 'function' && typeof connect !== 'object') {
throw new InvalidArgumentError('connect must be a function or an object')
}
if (typeof connect !== 'function') {
connect = buildConnector({
...tls,
maxCachedSessions,
allowH2,
useH2c,
socketPath,
timeout: connectTimeout,
...(typeof autoSelectFamily === 'boolean' ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined),
...connect
})
}
super(options)
this[kConnections] = connections || null
this[kUrl] = util.parseOrigin(origin)
this[kOptions] = { ...util.deepClone(options), connect, allowH2, useH2c, clientTtl, socketPath }
this[kFactory] = factory
this[kProtocol] = null
this[kProtocolProbe] = null
this.on('connect', (origin, targets) => {
if (clientTtl != null && clientTtl > 0) {
for (const target of targets) {
Object.assign(target, { ttl: Date.now() })
}
}
const client = targets[targets.length - 1]
if (client instanceof Client) {
this[kProtocol] = client[kHTTPContext]?.version
}
if (client === this[kProtocolProbe]) {
// An h2 Client's drain event releases the requests accumulated during
// negotiation onto that Client. If ALPN selected h1, release the probe
// immediately and restore normal Pool fan-out instead.
if (this[kProtocol] !== 'h2') {
this[kProtocolProbe] = null
this[kDrainQueue](origin, targets.slice(1))
}
}
})
this.on('disconnect', (origin, targets) => {
if (targets.includes(this[kProtocolProbe])) {
this[kProtocolProbe] = null
this[kDrainQueue](origin, targets.slice(1))
}
})
this.on('connectionError', (origin, targets) => {
let resumeQueued = false
// If a connection error occurs, we remove the client from the pool,
// and emit a connectionError event. They will not be re-used.
// Fixes https://github.com/nodejs/undici/issues/3895
for (const target of targets) {
if (target === this[kProtocolProbe]) {
this[kProtocolProbe] = null
resumeQueued = true
}
// Do not use kRemoveClient here, as it will close the client,
// but the client cannot be closed in this state.
const idx = this[kClients].indexOf(target)
if (idx !== -1) {
this[kClients].splice(idx, 1)
}
}
if (resumeQueued) {
this[kDrainQueue](origin, targets.slice(1))
}
})
}
[kOnClientBusy] (client) {
if (
this[kProtocolProbe] === null &&
client[kConnecting] &&
shouldCreateProtocolProbe(this, client)
) {
this[kProtocolProbe] = client
}
}
[kOnClientDrain] (client) {
if (client === this[kProtocolProbe]) {
this[kProtocolProbe] = null
}
}
[kGetDispatcher] () {
const clientTtlOption = this[kOptions].clientTtl
for (let i = 0; i < this[kClients].length; i++) {
const client = this[kClients][i]
// check ttl of client and if it's stale, remove it from the pool
if (clientTtlOption != null && clientTtlOption > 0 && client.ttl && ((Date.now() - client.ttl) > clientTtlOption)) {
this[kRemoveClient](client)
i--
} else if (!client[kNeedDrain]) {
return client
}
}
if (this[kProtocolProbe] !== null) {
return
}
if (!this[kConnections] || this[kClients].length < this[kConnections]) {
return createClient(this)
}
}
[kHasDispatcher] () {
const clientTtlOption = this[kOptions].clientTtl
for (let i = 0; i < this[kClients].length; i++) {
const client = this[kClients][i]
if (clientTtlOption != null && clientTtlOption > 0 && client.ttl && ((Date.now() - client.ttl) > clientTtlOption)) {
this[kRemoveClient](client)
i--
} else if (!client[kNeedDrain]) {
return true
}
}
if (this[kProtocolProbe] !== null) {
return false
}
if (!this[kConnections] || this[kClients].length < this[kConnections]) {
createClient(this)
return true
}
return false
}
}
module.exports = Pool
+408
View File
@@ -0,0 +1,408 @@
'use strict'
const { kProxy, kClose, kDestroy, kDispatch } = require('../core/symbols')
const Agent = require('./agent')
const Pool = require('./pool')
const DispatcherBase = require('./dispatcher-base')
const { InvalidArgumentError, RequestAbortedError, SecureProxyConnectionError, ProxyConnectionError } = require('../core/errors')
const buildConnector = require('../core/connect')
const Client = require('./client')
const { channels } = require('../core/diagnostics')
const Socks5ProxyAgent = require('./socks5-proxy-agent')
const { hasSafeIterator } = require('../core/util')
const kAgent = Symbol('proxy agent')
const kClient = Symbol('proxy client')
const kProxyHeaders = Symbol('proxy headers')
const kRequestTls = Symbol('request tls settings')
const kProxyTls = Symbol('proxy tls settings')
const kConnectEndpoint = Symbol('connect endpoint function')
const kConnectEndpointHTTP1 = Symbol('connect endpoint function (http/1.1 only)')
const kTunnelProxy = Symbol('tunnel proxy')
const proxyAuthorization = 'proxy-authorization'
function defaultProtocolPort (protocol) {
return protocol === 'https:' ? 443 : 80
}
function defaultFactory (origin, opts) {
return new Pool(origin, opts)
}
const noop = () => {}
function defaultAgentFactory (origin, opts) {
if (opts.connections === 1) {
return new Client(origin, opts)
}
return new Pool(origin, opts)
}
function shouldProxyTunnel (requestProtocol, proxyTunnel) {
return proxyTunnel === true || requestProtocol !== 'http:'
}
class Http1ProxyWrapper extends DispatcherBase {
#client
#proxyServername
constructor (proxyUrl, { headers = {}, connect, factory, proxyServername }) {
if (!proxyUrl) {
throw new InvalidArgumentError('Proxy URL is mandatory')
}
super()
this[kProxyHeaders] = headers
this.#proxyServername = proxyServername
if (factory) {
this.#client = factory(proxyUrl, { connect })
} else {
this.#client = new Client(proxyUrl, { connect })
}
}
[kDispatch] (opts, handler) {
const onResponseStart = handler.onResponseStart
handler.onResponseStart = function (controller, statusCode, data, statusMessage) {
if (statusCode === 407) {
if (typeof handler.onResponseError === 'function') {
handler.onResponseError(controller, new InvalidArgumentError('Proxy Authentication Required (407)'))
}
return
}
if (onResponseStart) onResponseStart.call(this, controller, statusCode, data, statusMessage)
}
// Rewrite request as an HTTP1 Proxy request, without tunneling.
const {
origin,
path = '/',
headers = {}
} = opts
opts.path = origin + path
if (!('host' in headers) && !('Host' in headers)) {
const { host } = new URL(origin)
headers.host = host
}
opts.headers = { ...this[kProxyHeaders], ...headers }
// Pin the SNI/cert hostname to the proxy. Without this the underlying
// Client would derive it from the (rewritten) Host header, which points
// at the target — wrong for the TLS handshake to the proxy itself.
if (this.#proxyServername != null) {
opts.servername = this.#proxyServername
}
return this.#client[kDispatch](opts, handler)
}
[kClose] () {
return this.#client.close()
}
[kDestroy] (err) {
return this.#client.destroy(err)
}
}
class ProxyAgent extends DispatcherBase {
constructor (opts) {
if (!opts || (typeof opts === 'object' && !(opts instanceof URL) && !opts.uri)) {
throw new InvalidArgumentError('Proxy uri is mandatory')
}
const { clientFactory = defaultFactory } = opts
if (typeof clientFactory !== 'function') {
throw new InvalidArgumentError('Proxy opts.clientFactory must be a function.')
}
const { proxyTunnel, connectTimeout } = opts
super(opts)
const url = this.#getUrl(opts)
const { href, origin, port, protocol, username, password, hostname: proxyHostname } = url
this[kProxy] = { uri: href, protocol }
this[kRequestTls] = opts.requestTls
this[kProxyTls] = opts.proxyTls
this[kProxyHeaders] = opts.headers || {}
this[kTunnelProxy] = proxyTunnel
if (opts.auth && opts.token) {
throw new InvalidArgumentError('opts.auth cannot be used in combination with opts.token')
} else if (opts.auth) {
/* @deprecated in favour of opts.token */
this[kProxyHeaders]['proxy-authorization'] = `Basic ${opts.auth}`
} else if (opts.token) {
this[kProxyHeaders]['proxy-authorization'] = opts.token
} else if (username && password) {
this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}`
} else if (username) {
this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from(`${decodeURIComponent(username)}:`).toString('base64')}`
}
const connect = buildConnector({ timeout: connectTimeout, ...opts.proxyTls })
const connectHTTP1 = buildConnector({ timeout: connectTimeout, ...opts.proxyTls, allowH2: false })
this[kConnectEndpoint] = buildConnector({ timeout: connectTimeout, ...opts.requestTls })
this[kConnectEndpointHTTP1] = buildConnector({ timeout: connectTimeout, ...opts.requestTls, allowH2: false })
const agentFactory = opts.factory || defaultAgentFactory
const factory = (origin, options) => {
const { protocol } = new URL(origin)
// Handle SOCKS5 proxy
if (this[kProxy].protocol === 'socks5:' || this[kProxy].protocol === 'socks:') {
return new Socks5ProxyAgent(this[kProxy].uri, {
headers: this[kProxyHeaders],
connect,
factory: agentFactory,
username: opts.username || username,
password: opts.password || password,
connectTimeout,
proxyTls: opts.proxyTls,
requestTls: opts.requestTls
})
}
if (!shouldProxyTunnel(protocol, this[kTunnelProxy])) {
const forwardConnect = this[kProxy].protocol === 'https:'
? (opts, cb) => connectHTTP1(opts, (err, socket) => {
if (err && err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
cb(new SecureProxyConnectionError(err))
} else {
cb(err, socket)
}
})
: connectHTTP1
return new Http1ProxyWrapper(this[kProxy].uri, {
headers: this[kProxyHeaders],
connect: forwardConnect,
factory: agentFactory,
proxyServername: this[kProxy].protocol === 'https:'
? (this[kProxyTls]?.servername || proxyHostname)
: undefined
})
}
return agentFactory(origin, options)
}
// For SOCKS5 proxies, we don't need a client to the proxy itself
// The SOCKS5 connection is handled within Socks5ProxyAgent
if (protocol === 'socks5:' || protocol === 'socks:') {
this[kClient] = null
} else {
this[kClient] = clientFactory(url, { connect })
}
this[kAgent] = new Agent({
...opts,
factory,
connect: async (opts, callback) => {
// SOCKS5 proxies handle their own connections via Socks5ProxyAgent,
// so this connect function should never be called for them.
if (!this[kClient]) {
callback(new InvalidArgumentError('Cannot establish tunnel connection without a proxy client'))
return
}
let requestedPath = opts.host
if (!opts.port) {
requestedPath += `:${defaultProtocolPort(opts.protocol)}`
}
try {
const connectParams = {
origin,
port,
path: requestedPath,
signal: opts.signal,
headers: {
...this[kProxyHeaders],
host: opts.host,
...(opts.connections == null || opts.connections > 0 ? { 'proxy-connection': 'keep-alive' } : {})
},
servername: this[kProxyTls]?.servername || proxyHostname
}
const { socket, statusCode } = await this[kClient].connect(connectParams)
if (statusCode !== 200) {
socket.on('error', noop).destroy()
callback(new RequestAbortedError(`Proxy response (${statusCode}) !== 200 when HTTP Tunneling`))
return
}
if (channels.proxyConnected.hasSubscribers) {
channels.proxyConnected.publish({
socket,
connectParams
})
}
if (opts.protocol !== 'https:') {
callback(null, socket)
return
}
let servername
if (this[kRequestTls]) {
servername = this[kRequestTls].servername
} else {
servername = opts.servername
}
const connectEndpoint = opts.allowH2 === false
? this[kConnectEndpointHTTP1]
: this[kConnectEndpoint]
connectEndpoint({ ...opts, servername, httpSocket: socket }, callback)
} catch (err) {
if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
// Throw a custom error to avoid loop in client.js#connect
callback(new SecureProxyConnectionError(err))
} else if (err.code === 'UND_ERR_SOCKET') {
// A socket failure while establishing the tunnel means the CONNECT
// never completed, so there is nothing to recover - the proxy just
// tore down the connection. client.js#onError treats UND_ERR_SOCKET
// as a recoverable error on an established connection and leaves the
// request queued, which makes connect() retry forever. Surface it as
// a non-recoverable proxy error so the request fails instead. (#3897)
callback(new ProxyConnectionError(err))
} else {
callback(err)
}
}
}
})
}
dispatch (opts, handler) {
const headers = buildHeaders(opts.headers)
throwIfProxyAuthIsSent(headers)
if (headers && !('host' in headers) && !('Host' in headers)) {
const { host } = new URL(opts.origin)
headers.host = host
}
return this[kAgent].dispatch(
{
...opts,
headers
},
handler
)
}
/**
* @param {import('../../types/proxy-agent').ProxyAgent.Options | string | URL} opts
* @returns {URL}
*/
#getUrl (opts) {
if (typeof opts === 'string') {
return new URL(opts)
} else if (opts instanceof URL) {
return opts
} else {
return new URL(opts.uri)
}
}
[kClose] () {
const promises = [this[kAgent].close()]
if (this[kClient]) {
promises.push(this[kClient].close())
}
return Promise.all(promises)
}
[kDestroy] () {
const promises = [this[kAgent].destroy()]
if (this[kClient]) {
promises.push(this[kClient].destroy())
}
return Promise.all(promises)
}
}
/**
* @param {string[] | Record<string, string> | Iterable<[string, string | string[] | undefined]>} headers
* @returns {Record<string, string | string[] | undefined>}
*/
function buildHeaders (headers) {
// When using undici.fetch, the headers list is stored
// as an array.
if (Array.isArray(headers)) {
/** @type {Record<string, string>} */
const headersPair = {}
for (let i = 0; i < headers.length; i += 2) {
if (isProxyAuthorizationHeader(headers[i])) {
throwProxyAuthError()
}
headersPair[headers[i]] = headers[i + 1]
}
return headersPair
}
// Materialize iterable header containers (e.g. Map, Headers) into a record so
// that throwIfProxyAuthIsSent() can inspect their entries. Object.keys and
// for...in see nothing on a Map/Headers instance, so without this the
// Proxy-Authorization guard is bypassed and proxy credentials can reach the
// origin server (GHSA-6cv7-626c-qhqw).
if (headers && typeof headers === 'object' && hasSafeIterator(headers)) {
const headersPair = {}
for (const [key, value] of headers) {
if (!Object.hasOwn(headersPair, key)) {
headersPair[key] = value
continue
}
const previous = headersPair[key]
const values = []
if (previous !== undefined) {
values.push(...(Array.isArray(previous) ? previous : [previous]))
}
if (value !== undefined) {
values.push(...(Array.isArray(value) ? value : [value]))
}
headersPair[key] = values.length > 1 ? values : values[0]
}
return headersPair
}
return headers
}
/**
* @param {Record<string, string>} headers
*
* Previous versions of ProxyAgent suggests the Proxy-Authorization in request headers
* Nevertheless, it was changed and to avoid a security vulnerability by end users
* this check was created.
* It should be removed in the next major version for performance reasons
*/
function throwIfProxyAuthIsSent (headers) {
for (const key in headers) {
if (isProxyAuthorizationHeader(key)) {
throwProxyAuthError()
}
}
}
/**
* @param {string} key
* @returns {boolean}
*/
function isProxyAuthorizationHeader (key) {
return key.length === proxyAuthorization.length && key.toLowerCase() === proxyAuthorization
}
function throwProxyAuthError () {
throw new InvalidArgumentError('Proxy-Authorization should be sent in ProxyAgent constructor')
}
module.exports = ProxyAgent
+38
View File
@@ -0,0 +1,38 @@
'use strict'
const Dispatcher = require('./dispatcher')
const RetryHandler = require('../handler/retry-handler')
const { kOriginless, kUrl } = require('../core/symbols')
class RetryAgent extends Dispatcher {
#agent = null
#options = null
constructor (agent, options = {}) {
super(options)
this.#agent = agent
this.#options = options
this[kUrl] = agent[kUrl]
this[kOriginless] = agent[kOriginless]
}
dispatch (opts, handler) {
const retry = new RetryHandler({
...opts,
retryOptions: this.#options
}, {
dispatch: this.#agent.dispatch.bind(this.#agent),
handler
})
return this.#agent.dispatch(opts, retry)
}
close () {
return this.#agent.close()
}
destroy () {
return this.#agent.destroy()
}
}
module.exports = RetryAgent
+159
View File
@@ -0,0 +1,159 @@
'use strict'
const {
PoolBase,
kClients,
kNeedDrain,
kAddClient,
kGetDispatcher,
kHasDispatcher,
kRemoveClient
} = require('./pool-base')
const Client = require('./client')
const {
InvalidArgumentError
} = require('../core/errors')
const util = require('../core/util')
const { kUrl } = require('../core/symbols')
const buildConnector = require('../core/connect')
const kOptions = Symbol('options')
const kConnections = Symbol('connections')
const kFactory = Symbol('factory')
const kIndex = Symbol('index')
function defaultFactory (origin, opts) {
return new Client(origin, opts)
}
class RoundRobinPool extends PoolBase {
constructor (origin, {
connections,
factory = defaultFactory,
connect,
connectTimeout,
tls,
maxCachedSessions,
socketPath,
autoSelectFamily,
autoSelectFamilyAttemptTimeout,
allowH2,
clientTtl,
...options
} = {}) {
if (connections != null && (!Number.isFinite(connections) || connections < 0)) {
throw new InvalidArgumentError('invalid connections')
}
if (typeof factory !== 'function') {
throw new InvalidArgumentError('factory must be a function.')
}
if (connect != null && typeof connect !== 'function' && typeof connect !== 'object') {
throw new InvalidArgumentError('connect must be a function or an object')
}
if (typeof connect !== 'function') {
connect = buildConnector({
...tls,
maxCachedSessions,
allowH2,
socketPath,
timeout: connectTimeout,
...(typeof autoSelectFamily === 'boolean' ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined),
...connect
})
}
super(options)
this[kConnections] = connections || null
this[kUrl] = util.parseOrigin(origin)
this[kOptions] = { ...util.deepClone(options), connect, allowH2, clientTtl, socketPath }
this[kFactory] = factory
this[kIndex] = -1
this.on('connect', (origin, targets) => {
if (clientTtl != null && clientTtl > 0) {
for (const target of targets) {
Object.assign(target, { ttl: Date.now() })
}
}
})
this.on('connectionError', (origin, targets, error) => {
for (const target of targets) {
const idx = this[kClients].indexOf(target)
if (idx !== -1) {
this[kClients].splice(idx, 1)
}
}
})
}
[kGetDispatcher] () {
const clientTtlOption = this[kOptions].clientTtl
// If we have no clients yet, create one
if (this[kClients].length === 0) {
const dispatcher = this[kFactory](this[kUrl], this[kOptions])
this[kAddClient](dispatcher)
return dispatcher
}
// Round-robin through existing clients
let checked = 0
while (checked < this[kClients].length) {
this[kIndex] = (this[kIndex] + 1) % this[kClients].length
const client = this[kClients][this[kIndex]]
// Check if client is stale (TTL expired)
if (clientTtlOption != null && clientTtlOption > 0 && client.ttl && ((Date.now() - client.ttl) > clientTtlOption)) {
this[kRemoveClient](client)
this[kIndex]--
continue
}
// Return client if it's not draining
if (!client[kNeedDrain]) {
return client
}
checked++
}
// All clients are busy, create a new one if we haven't reached the limit
if (!this[kConnections] || this[kClients].length < this[kConnections]) {
const dispatcher = this[kFactory](this[kUrl], this[kOptions])
this[kAddClient](dispatcher)
return dispatcher
}
}
[kHasDispatcher] () {
const clientTtlOption = this[kOptions].clientTtl
for (let i = 0; i < this[kClients].length; i++) {
const client = this[kClients][i]
if (clientTtlOption != null && clientTtlOption > 0 && client.ttl && ((Date.now() - client.ttl) > clientTtlOption)) {
this[kRemoveClient](client)
if (i <= this[kIndex]) {
this[kIndex]--
}
i--
} else if (!client[kNeedDrain]) {
return true
}
}
if (!this[kConnections] || this[kClients].length < this[kConnections]) {
const dispatcher = this[kFactory](this[kUrl], this[kOptions])
this[kAddClient](dispatcher)
return true
}
return false
}
}
module.exports = RoundRobinPool
+350
View File
@@ -0,0 +1,350 @@
'use strict'
const { URL } = require('node:url')
let tls // include tls conditionally since it is not always available
const DispatcherBase = require('./dispatcher-base')
const { ConnectTimeoutError, InvalidArgumentError } = require('../core/errors')
const { Socks5Client, STATES } = require('../core/socks5-client')
const { kBusy, kConnected, kDispatch, kClose, kDestroy } = require('../core/symbols')
const Pool = require('./pool')
const buildConnector = require('../core/connect')
const { setupConnectTimeout } = require('../core/util')
const { debuglog } = require('node:util')
const debug = debuglog('undici:socks5-proxy')
const DEFAULT_SOCKS5_CONNECT_TIMEOUT = 5000
const kProxyUrl = Symbol('proxy url')
const kProxyHeaders = Symbol('proxy headers')
const kProxyAuth = Symbol('proxy auth')
const kProxyProtocol = Symbol('proxy protocol')
const kPools = Symbol('pools')
const kConnector = Symbol('connector')
const kConnectTimeout = Symbol('connect timeout')
const kRequestTls = Symbol('request tls settings')
const kRequestTlsTimeout = Symbol('request tls timeout')
function createConnectTimeoutError (hostname, port, timeout) {
return new ConnectTimeoutError(
`Connect Timeout Error (attempted address: ${hostname}:${port}, timeout: ${timeout}ms)`
)
}
// Static flag to ensure warning is only emitted once per process
let experimentalWarningEmitted = false
/**
* SOCKS5 proxy agent for dispatching requests through a SOCKS5 proxy
*/
class Socks5ProxyAgent extends DispatcherBase {
constructor (proxyUrl, options = {}) {
super(options)
// Emit experimental warning only once
if (!experimentalWarningEmitted) {
process.emitWarning(
'SOCKS5 proxy support is experimental and subject to change',
'ExperimentalWarning'
)
experimentalWarningEmitted = true
}
if (!proxyUrl) {
throw new InvalidArgumentError('Proxy URL is mandatory')
}
// Parse proxy URL
const url = typeof proxyUrl === 'string' ? new URL(proxyUrl) : proxyUrl
if (url.protocol !== 'socks5:' && url.protocol !== 'socks:') {
throw new InvalidArgumentError('Proxy URL must use socks5:// or socks:// protocol')
}
this[kProxyUrl] = url
this[kProxyHeaders] = options.headers || {}
this[kProxyProtocol] = options.proxyTls ? 'https:' : 'http:'
const connectTimeout = options.connectTimeout ?? DEFAULT_SOCKS5_CONNECT_TIMEOUT
if (!Number.isFinite(connectTimeout) || connectTimeout < 0) {
throw new InvalidArgumentError('invalid connectTimeout')
}
this[kConnectTimeout] = connectTimeout
const { timeout, ...requestTls } = options.requestTls || {}
const requestTlsTimeout = timeout ?? connectTimeout
if (!Number.isFinite(requestTlsTimeout) || requestTlsTimeout < 0) {
throw new InvalidArgumentError('invalid requestTls.timeout')
}
this[kRequestTls] = requestTls
this[kRequestTlsTimeout] = requestTlsTimeout
// Extract auth from URL or options
this[kProxyAuth] = {
username: options.username || (url.username ? decodeURIComponent(url.username) : null),
password: options.password || (url.password ? decodeURIComponent(url.password) : null)
}
// Create connector for proxy connection
const proxyTlsTimeout = options.proxyTls?.timeout ?? connectTimeout
if (!Number.isFinite(proxyTlsTimeout) || proxyTlsTimeout < 0) {
throw new InvalidArgumentError('invalid proxyTls.timeout')
}
this[kConnector] = options.connect || buildConnector({
...options.proxyTls,
timeout: proxyTlsTimeout,
servername: options.proxyTls?.servername || url.hostname
})
// Pools for the actual HTTP connections (with SOCKS5 tunnel connect function), keyed by origin
this[kPools] = new Map()
}
/**
* Create a SOCKS5 connection to the proxy
*/
async createSocks5Connection (targetHost, targetPort) {
const proxyHost = this[kProxyUrl].hostname
const proxyPort = parseInt(this[kProxyUrl].port) || 1080
debug('creating SOCKS5 connection to', proxyHost, proxyPort)
// Connect to the SOCKS5 proxy
const socketReady = Promise.withResolvers()
this[kConnector]({
hostname: proxyHost,
host: proxyHost,
port: proxyPort,
protocol: this[kProxyProtocol]
}, (err, socket) => {
if (err) {
socketReady.reject(err)
} else {
socketReady.resolve(socket)
}
})
const socket = await socketReady.promise
// Create SOCKS5 client
const socks5Client = new Socks5Client(socket, this[kProxyAuth])
// Handle SOCKS5 errors
socks5Client.on('error', (err) => {
debug('SOCKS5 error:', err)
socket.destroy()
})
// Perform SOCKS5 handshake
await socks5Client.handshake()
// Wait for authentication (if required)
const authenticationReady = Promise.withResolvers()
const authenticationTimeout = this[kConnectTimeout] === 0
? null
: setTimeout(() => {
cleanupAuthenticationListeners()
socks5Client.destroy()
authenticationReady.reject(
createConnectTimeoutError(proxyHost, proxyPort, this[kConnectTimeout])
)
}, this[kConnectTimeout])
const cleanupAuthenticationListeners = () => {
clearTimeout(authenticationTimeout)
socks5Client.removeListener('authenticated', onAuthenticated)
socks5Client.removeListener('error', onAuthenticationError)
}
const onAuthenticated = () => {
cleanupAuthenticationListeners()
authenticationReady.resolve()
}
const onAuthenticationError = (err) => {
cleanupAuthenticationListeners()
authenticationReady.reject(err)
}
// Check if already authenticated (for NO_AUTH method)
if (socks5Client.state === STATES.AUTHENTICATED) {
clearTimeout(authenticationTimeout)
authenticationReady.resolve()
} else {
socks5Client.once('authenticated', onAuthenticated)
socks5Client.once('error', onAuthenticationError)
}
await authenticationReady.promise
// Send CONNECT command
await socks5Client.connect(targetHost, targetPort)
// Wait for connection
const connectionReady = Promise.withResolvers()
const connectionTimeout = this[kConnectTimeout] === 0
? null
: setTimeout(() => {
cleanupConnectionListeners()
socks5Client.destroy()
connectionReady.reject(
createConnectTimeoutError(targetHost, targetPort, this[kConnectTimeout])
)
}, this[kConnectTimeout])
const cleanupConnectionListeners = () => {
clearTimeout(connectionTimeout)
socks5Client.removeListener('connected', onConnected)
socks5Client.removeListener('error', onConnectionError)
}
const onConnected = (info) => {
debug('SOCKS5 tunnel established to', targetHost, targetPort, 'via', info)
cleanupConnectionListeners()
connectionReady.resolve()
}
const onConnectionError = (err) => {
cleanupConnectionListeners()
connectionReady.reject(err)
}
socks5Client.once('connected', onConnected)
socks5Client.once('error', onConnectionError)
await connectionReady.promise
return socket
}
/**
* Dispatch a request through the SOCKS5 proxy
*/
[kDispatch] (opts, handler) {
const { origin } = opts
debug('dispatching request to', origin, 'via SOCKS5')
try {
const originKey = String(origin)
let pool = this[kPools].get(originKey)
// Create a Pool per origin so requests are not routed to the wrong host
if (!pool || pool.destroyed || pool.closed) {
pool = new Pool(origin, {
pipelining: opts.pipelining,
connections: opts.connections,
connect: async (connectOpts, callback) => {
try {
const url = new URL(origin)
const targetHost = url.hostname
const targetPort = parseInt(url.port) || (url.protocol === 'https:' ? 443 : 80)
debug('establishing SOCKS5 connection to', targetHost, targetPort)
// Create SOCKS5 tunnel
const socket = await this.createSocks5Connection(targetHost, targetPort)
// Handle TLS if needed
let finalSocket = socket
if (url.protocol === 'https:') {
if (!tls) {
tls = require('node:tls')
}
debug('upgrading to TLS')
finalSocket = tls.connect({
...this[kRequestTls],
socket,
servername: this[kRequestTls]?.servername || targetHost
})
const tlsReady = Promise.withResolvers()
const cleanupTlsListeners = () => {
queueMicrotask(clearTlsTimeout)
finalSocket.removeListener('secureConnect', onSecureConnect)
finalSocket.removeListener('error', onTlsError)
}
const onSecureConnect = () => {
cleanupTlsListeners()
tlsReady.resolve()
}
const onTlsError = (err) => {
cleanupTlsListeners()
tlsReady.reject(err)
}
const clearTlsTimeout = setupConnectTimeout(new WeakRef(finalSocket), {
timeout: this[kRequestTlsTimeout],
hostname: targetHost,
port: targetPort
})
finalSocket.once('secureConnect', onSecureConnect)
finalSocket.once('error', onTlsError)
await tlsReady.promise
}
callback(null, finalSocket)
} catch (err) {
debug('SOCKS5 connection error:', err)
callback(err)
}
}
})
this[kPools].set(originKey, pool)
const closePoolIfUnused = () => {
if (this[kPools].get(originKey) !== pool || pool[kConnected] > 0 || pool[kBusy]) {
return
}
this[kPools].delete(originKey)
if (!pool.destroyed) {
pool.close()
}
}
pool.on('disconnect', closePoolIfUnused)
pool.on('connectionError', closePoolIfUnused)
}
// Dispatch the request through the per-origin pool
return pool[kDispatch](opts, handler)
} catch (err) {
debug('dispatch error:', err)
if (typeof handler.onResponseError === 'function') {
handler.onResponseError(null, err)
return false
} else if (typeof handler.onError === 'function') {
handler.onError(err)
return false
} else {
throw err
}
}
}
async [kClose] () {
const closePromises = []
for (const pool of this[kPools].values()) {
closePromises.push(pool.close())
}
this[kPools].clear()
await Promise.all(closePromises)
}
async [kDestroy] (err) {
const destroyPromises = []
for (const pool of this[kPools].values()) {
destroyPromises.push(pool.destroy(err))
}
this[kPools].clear()
await Promise.all(destroyPromises)
}
}
module.exports = Socks5ProxyAgent