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:
1 parent
db63a29365
commit
6ba93b2a92
308 files changed
+64441
-743
No files matched your search
BIN
Binary file not shown.
+58
@@ -4,6 +4,55 @@
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.10.tgz",
|
||||
"integrity": "sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "1.0.10",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.0.10",
|
||||
"@napi-rs/canvas-darwin-x64": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.0.10",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.0.10",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.0.10",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.10.tgz",
|
||||
"integrity": "sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.15",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
|
||||
@@ -1029,6 +1078,15 @@
|
||||
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.11.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz",
|
||||
"integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# `@napi-rs/canvas-darwin-arm64`
|
||||
|
||||
This is the **aarch64-apple-darwin** binary for `@napi-rs/canvas`
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@napi-rs/canvas-darwin-arm64",
|
||||
"version": "1.0.10",
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"main": "skia.darwin-arm64.node",
|
||||
"files": [
|
||||
"skia.darwin-arm64.node"
|
||||
],
|
||||
"description": "Canvas for Node.js with skia backend",
|
||||
"keywords": [
|
||||
"napi-rs",
|
||||
"NAPI",
|
||||
"N-API",
|
||||
"Rust",
|
||||
"node-addon",
|
||||
"node-addon-api",
|
||||
"canvas",
|
||||
"image",
|
||||
"pdf",
|
||||
"svg",
|
||||
"skia"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Brooooooklyn/canvas.git"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 lynweklm@gmail.com
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
# `skr canvas`
|
||||
|
||||
[](https://github.com/Brooooooklyn/canvas/actions/workflows/CI.yaml)
|
||||

|
||||
[](https://packagephobia.com/result?p=@napi-rs/canvas)
|
||||
[](https://npmcharts.com/compare/@napi-rs/canvas?minimal=true)
|
||||
|
||||
> 🚀 Help me to become a full-time open-source developer by [sponsoring me on Github](https://github.com/sponsors/Brooooooklyn)
|
||||
|
||||
Google Skia binding to Node.js via [Node-API](https://napi.rs), **0 System dependencies!**
|
||||
|
||||
[中文文档](./README-zh.md)
|
||||
|
||||
# Install
|
||||
|
||||
```bash
|
||||
yarn add @napi-rs/canvas
|
||||
npm install @napi-rs/canvas
|
||||
```
|
||||
|
||||
# Support matrix
|
||||
|
||||
## System requirement
|
||||
|
||||
### `arm64`
|
||||
|
||||
[**_cortex-a57_**](https://en.wikipedia.org/wiki/ARM_Cortex-A57) or newer CPU architecture on **Linux**.
|
||||
|
||||
All Apple M chips on **macOS**.
|
||||
|
||||
### `armv7`
|
||||
|
||||
[**_cortex-a7_**](https://en.wikipedia.org/wiki/ARM_Cortex-A7) or newer CPU architecture.
|
||||
|
||||
### glibc
|
||||
|
||||
Since Skia relies on the [glibc](https://www.gnu.org/software/libc/) 2.18 API, you need to have at least glibc version >= 2.18 on your system.
|
||||
|
||||
## AWS Lambda usage
|
||||
|
||||
To use this library on Lambda you will need to use a Lambda layer.
|
||||
|
||||
You can simply attach a lambda layer by getting an ARN from [Canvas-Lambda-Layer](https://github.com/ShivamJoker/Canvas-Lambda-Layer)
|
||||
|
||||
> Make sure to exclude `@napi-rs/canvas` while bundling your Lambda.
|
||||
|
||||
# Usage
|
||||
|
||||
```js
|
||||
const { promises } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const { createCanvas, loadImage } = require('@napi-rs/canvas')
|
||||
|
||||
const canvas = createCanvas(300, 320)
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
ctx.lineWidth = 10
|
||||
ctx.strokeStyle = '#03a9f4'
|
||||
ctx.fillStyle = '#03a9f4'
|
||||
|
||||
// Wall
|
||||
ctx.strokeRect(75, 140, 150, 110)
|
||||
|
||||
// Door
|
||||
ctx.fillRect(130, 190, 40, 60)
|
||||
|
||||
// Roof
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(50, 140)
|
||||
ctx.lineTo(150, 60)
|
||||
ctx.lineTo(250, 140)
|
||||
ctx.closePath()
|
||||
ctx.stroke()
|
||||
|
||||
async function main() {
|
||||
// load images from disk or from a URL
|
||||
const catImage = await loadImage('path/to/cat.png')
|
||||
const dogImage = await loadImage('https://example.com/path/to/dog.jpg')
|
||||
|
||||
ctx.drawImage(catImage, 0, 0, catImage.width, catImage.height)
|
||||
|
||||
ctx.drawImage(dogImage, canvas.width / 2, canvas.height / 2, dogImage.width, dogImage.height)
|
||||
|
||||
// export canvas as image
|
||||
const pngData = await canvas.encode('png') // JPEG, AVIF and WebP are also supported
|
||||
// encoding in libuv thread pool, non-blocking
|
||||
await promises.writeFile(join(__dirname, 'simple.png'), pngData)
|
||||
}
|
||||
|
||||
main()
|
||||
```
|
||||
|
||||

|
||||
|
||||
## Emoji text
|
||||
|
||||
```js
|
||||
const { writeFileSync } = require('fs')
|
||||
const { join } = require('path')
|
||||
|
||||
const { createCanvas, GlobalFonts } = require('@napi-rs/canvas')
|
||||
|
||||
GlobalFonts.registerFromPath(join(__dirname, '..', 'fonts', 'AppleColorEmoji@2x.ttf'), 'Apple Emoji')
|
||||
GlobalFonts.registerFromPath(join(__dirname, '..', '__test__', 'fonts', 'COLRv1.ttf'), 'COLRv1')
|
||||
|
||||
console.info(GlobalFonts.families)
|
||||
|
||||
const canvas = createCanvas(760, 360)
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
ctx.font = '50px Apple Emoji'
|
||||
ctx.strokeText('😀😃😄😁😆😅😂🤣☺️😊😊😇', 50, 150)
|
||||
|
||||
ctx.font = '100px COLRv1'
|
||||
ctx.fillText('abc', 50, 300)
|
||||
|
||||
const b = canvas.toBuffer('image/png')
|
||||
|
||||
writeFileSync(join(__dirname, 'draw-emoji.png'), b)
|
||||
```
|
||||
|
||||

|
||||
|
||||
# Performance
|
||||
|
||||
See [benchmark](./benchmark) for benchmark code.
|
||||
|
||||
Hardware info:
|
||||
|
||||
```
|
||||
,MMMM. Host - xxxxxxxxxxxxxxxxxxxxxxx
|
||||
.MMMMMM Machine - Mac15,9
|
||||
MMMMM, Kernel - 24.0.0
|
||||
.;MMMMM:' MMMMMMMMMM;. OS - macOS 15.0.1 Sequoia
|
||||
MMMMMMMMMMMMNWMMMMMMMMMMM: DE - Aqua
|
||||
.MMMMMMMMMMMMMMMMMMMMMMMMWM. WM - Quartz Compositor
|
||||
MMMMMMMMMMMMMMMMMMMMMMMMM. Packages - 194 (Homebrew), 32 (cargo)
|
||||
;MMMMMMMMMMMMMMMMMMMMMMMM: Shell - zsh
|
||||
:MMMMMMMMMMMMMMMMMMMMMMMM: Terminal - warpterminal (Version v0.2024.10.23.14.49.stable_00)
|
||||
.MMMMMMMMMMMMMMMMMMMMMMMMM. Resolution - 5120x2880@160fps (as 2560x1440)
|
||||
MMMMMMMMMMMMMMMMMMMMMMMMMMM. 2992x1934@120fps (as 1496x967)
|
||||
.MMMMMMMMMMMMMMMMMMMMMMMMMM. 2232x1512@60fps (as 1116x756)
|
||||
MMMMMMMMMMMMMMMMMMMMMMMM Uptime - 1d 2h 32m
|
||||
;MMMMMMMMMMMMMMMMMMMM. CPU - Apple M3 Max (16)
|
||||
.MMMM,. .MMMM,. CPU Load - 16%
|
||||
Memory - 50.1 GB / 134.2 GB
|
||||
Battery - 78% & Discharging
|
||||
Disk Space - 624.0 GB / 994.7 GB
|
||||
```
|
||||
|
||||
```
|
||||
❯ yarn bench
|
||||
Draw a House and export to PNG
|
||||
┌─────────┬─────────────────┬───────────────────────┬──────────────────────────┬────────────────────────────┬───────────────────────────┬─────────┐
|
||||
│ (index) │ Task name │ Latency average (ns) │ Latency median (ns) │ Throughput average (ops/s) │ Throughput median (ops/s) │ Samples │
|
||||
├─────────┼─────────────────┼───────────────────────┼──────────────────────────┼────────────────────────────┼───────────────────────────┼─────────┤
|
||||
│ 0 │ '@napi-rs/skia' │ '14676992.14 ± 0.68%' │ '14602333.00' │ '68 ± 0.59%' │ '68' │ 69 │
|
||||
│ 1 │ 'skia-canvas' │ '21167809.17 ± 2.05%' │ '20960021.00 ± 13646.00' │ '47 ± 1.31%' │ '48' │ 64 │
|
||||
│ 2 │ 'node-canvas' │ '16552027.42 ± 0.70%' │ '16451291.50 ± 2208.50' │ '60 ± 0.62%' │ '61' │ 64 │
|
||||
└─────────┴─────────────────┴───────────────────────┴──────────────────────────┴────────────────────────────┴───────────────────────────┴─────────┘
|
||||
Draw Gradient and export to PNG
|
||||
┌─────────┬─────────────────┬───────────────────────┬─────────────────────────┬────────────────────────────┬───────────────────────────┬─────────┐
|
||||
│ (index) │ Task name │ Latency average (ns) │ Latency median (ns) │ Throughput average (ops/s) │ Throughput median (ops/s) │ Samples │
|
||||
├─────────┼─────────────────┼───────────────────────┼─────────────────────────┼────────────────────────────┼───────────────────────────┼─────────┤
|
||||
│ 0 │ '@napi-rs/skia' │ '15228495.58 ± 0.53%' │ '15146312.50 ± 1187.50' │ '66 ± 0.48%' │ '66' │ 66 │
|
||||
│ 1 │ 'skia-canvas' │ '21725564.41 ± 2.20%' │ '21412520.50 ± 2104.50' │ '46 ± 1.39%' │ '47' │ 64 │
|
||||
│ 2 │ 'node-canvas' │ '17976022.14 ± 1.53%' │ '17563479.50 ± 5104.50' │ '56 ± 1.38%' │ '57' │ 64 │
|
||||
└─────────┴─────────────────┴───────────────────────┴─────────────────────────┴────────────────────────────┴───────────────────────────┴─────────┘
|
||||
```
|
||||
|
||||
# Features
|
||||
|
||||
## Path2D
|
||||
|
||||
```typescript
|
||||
new Path2D()
|
||||
new Path2D(path: Path2D)
|
||||
// new Path2D('M108.956,403.826c0,0,0.178,3.344-1.276,3.311 c-1.455-0.033-30.507-84.917-66.752-80.957C40.928,326.18,72.326,313.197,108.956,403.826z')
|
||||
new Path2D(path: string)
|
||||
```
|
||||
|
||||
```typescript
|
||||
export interface DOMMatrix2DInit {
|
||||
a: number
|
||||
b: number
|
||||
c: number
|
||||
d: number
|
||||
e: number
|
||||
f: number
|
||||
}
|
||||
|
||||
export class Path2D {
|
||||
constructor(path?: Path2D | string)
|
||||
|
||||
addPath(path: Path2D, transform?: DOMMatrix2DInit): void
|
||||
arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, anticlockwise?: boolean): void
|
||||
arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void
|
||||
bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void
|
||||
closePath(): void
|
||||
ellipse(
|
||||
x: number,
|
||||
y: number,
|
||||
radiusX: number,
|
||||
radiusY: number,
|
||||
rotation: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
anticlockwise?: boolean,
|
||||
): void
|
||||
lineTo(x: number, y: number): void
|
||||
moveTo(x: number, y: number): void
|
||||
quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void
|
||||
rect(x: number, y: number, w: number, h: number): void
|
||||
|
||||
// PathKit methods
|
||||
op(path: Path2D, operation: PathOp): Path2D
|
||||
toSVGString(): string
|
||||
getFillType(): FillType
|
||||
getFillTypeString(): string
|
||||
setFillType(type: FillType): void
|
||||
simplify(): Path2D
|
||||
asWinding(): Path2D
|
||||
stroke(stroke?: StrokeOptions): Path2D
|
||||
transform(transform: DOMMatrix2DInit): Path2D
|
||||
getBounds(): [left: number, top: number, right: number, bottom: number]
|
||||
computeTightBounds(): [left: number, top: number, right: number, bottom: number]
|
||||
trim(start: number, end: number, isComplement?: boolean): Path2D
|
||||
round(radius: number): Path2D
|
||||
equals(path: Path2D): boolean
|
||||
}
|
||||
```
|
||||
|
||||
## PathKit
|
||||
|
||||
`PathKit` is a toolset for manipulating Path in `Skia`, supporting **_quadratic beziers_**, **_cubic beziers_** and **_conics_**.
|
||||
The main features are.
|
||||
|
||||
### Path Operation
|
||||
|
||||
`.op(path, PathOp)`
|
||||
|
||||
```js
|
||||
const pathOne = new Path2D(
|
||||
'M8 50H92C96.4183 50 100 53.5817 100 58V142C100 146.418 96.4183 150 92 150H8C3.58172 150 0 146.418 0 142V58C0 53.5817 3.58172 50 8 50Z',
|
||||
)
|
||||
const pathTwo = new Path2D(
|
||||
'"M58 0H142C146.418 0 150 3.58172 150 8V92C150 96.4183 146.418 100 142 100H58C53.5817 100 50 96.4183 50 92V8C50 3.58172 53.5817 0 58 0Z',
|
||||
)
|
||||
|
||||
pathOne.op(pathTwo, PathOp.Intersect).toSVGString()
|
||||
// => "M100 100L58 100C53.5817 100 50 96.4183 50 92L50 50L92 50C96.4183 50 100 53.5817 100 58L100 100Z"
|
||||
```
|
||||
|
||||
- **Union**, subtract the op path from the first path
|
||||
- **Difference**, intersect the two paths
|
||||
- **ReverseDifference**, union (inclusive-or) the two paths
|
||||
- **Intersect**, exclusive-or the two paths
|
||||
- **XOR**, subtract the first path from the op path
|
||||
|
||||

|
||||
|
||||
### Covert `FillType` in **_Path_**
|
||||
|
||||
`.asWinding()`
|
||||
|
||||
You can convert `fill-rule="evenodd"` to `fill-rule="nonzero"` in SVG.
|
||||
This is useful for **OpenType** font-related tools, as `fill-rule="nonzero"` is only supported in **OpenType** fonts.
|
||||
|
||||

|
||||
|
||||
```js
|
||||
const pathCircle = new Path2D(
|
||||
'M24.2979 13.6364H129.394V40.9091H24.2979L14.6278 27.2727L24.2979 13.6364ZM21.9592 0C19.0246 0 16.2716 1.42436 14.571 3.82251L1.67756 22.0043C-0.559186 25.1585 -0.559186 29.387 1.67756 32.5411L14.571 50.7227C16.2716 53.1209 19.0246 54.5455 21.9592 54.5455H70.4673V68.1818H16.073C11.0661 68.1818 7.00728 72.2518 7.00728 77.2727V113.636C7.00728 118.657 11.0661 122.727 16.073 122.727H70.4673V150H84.0658V122.727H128.041C130.975 122.727 133.729 121.303 135.429 118.905L148.323 100.723C150.559 97.5686 150.559 93.3405 148.323 90.1864L135.429 72.0045C133.729 69.6064 130.975 68.1818 128.041 68.1818H84.0658V54.5455H133.927C138.934 54.5455 142.993 50.4755 142.993 45.4545V9.09091C142.993 4.07014 138.934 0 133.927 0H21.9592ZM125.702 109.091H20.6058V81.8182H125.702L135.372 95.4545L125.702 109.091Z',
|
||||
)
|
||||
pathCircle.setFillType(FillType.EvenOdd)
|
||||
pathCircle.asWinding().toSVGString()
|
||||
// => "M24.2979 13.6364L129.394 13.6364L129.394 40.9091L24.2979 40.9091L14.6278 27.2727L24.2979 13.6364ZM21.9592 0C19.0246 0 16.2716 1.42436 14.571 3.82251L1.67756 22.0043C-0.559186 25.1585 -0.559186 29.387 1.67756 32.5411L14.571 50.7227C16.2716 53.1209 19.0246 54.5455 21.9592 54.5455L70.4673 54.5455L70.4673 68.1818L16.073 68.1818C11.0661 68.1818 7.00728 72.2518 7.00728 77.2727L7.00728 113.636C7.00728 118.657 11.0661 122.727 16.073 122.727L70.4673 122.727L70.4673 150L84.0658 150L84.0658 122.727L128.041 122.727C130.975 122.727 133.729 121.303 135.429 118.905L148.323 100.723C150.559 97.5686 150.559 93.3405 148.323 90.1864L135.429 72.0045C133.729 69.6064 130.975 68.1818 128.041 68.1818L84.0658 68.1818L84.0658 54.5455L133.927 54.5455C138.934 54.5455 142.993 50.4755 142.993 45.4545L142.993 9.09091C142.993 4.07014 138.934 0 133.927 0L21.9592 0ZM125.702 109.091L20.6058 109.091L20.6058 81.8182L125.702 81.8182L135.372 95.4545L125.702 109.091Z"
|
||||
```
|
||||
|
||||
### Simplify **_Path_**
|
||||
|
||||
`.simplify()`
|
||||
|
||||
Set the path to the same non-overlapping contour as the original path area, which means that it can also remove overlapping paths.
|
||||
|
||||
<img width="800" src="./docs/imgs/simplify.png" >
|
||||
|
||||
[SVG with overlapping paths](./docs/imgs/overlapping-path.svg) (Left)
|
||||
|
||||
```js
|
||||
const path =
|
||||
'M2.933,89.89 L89.005,3.818 Q90.412,2.411 92.249,1.65 Q94.087,0.889 96.076,0.889 Q98.065,0.889 99.903,1.65 Q101.741,2.411 103.147,3.818 L189.22,89.89 Q190.626,91.296 191.387,93.134 Q192.148,94.972 192.148,96.961 Q192.148,98.95 191.387,100.788 Q190.626,102.625 189.219,104.032 Q187.813,105.439 185.975,106.2 Q184.138,106.961 182.148,106.961 Q180.159,106.961 178.322,106.2 Q176.484,105.439 175.077,104.032 L89.005,17.96 L96.076,10.889 L103.147,17.96 L17.075,104.032 Q15.668,105.439 13.831,106.2 Q11.993,106.961 10.004,106.961 Q8.015,106.961 6.177,106.2 Q4.339,105.439 2.933,104.032 Q1.526,102.625 0.765,100.788 Q0.004,98.95 0.004,96.961 Q0.004,94.972 0.765,93.134 Q1.526,91.296 2.933,89.89 Z'
|
||||
|
||||
path.simplify().toSVGString()
|
||||
// => "M89.005 3.818L2.933 89.89Q1.526 91.296 0.765 93.134Q0.004 94.972 0.004 96.961Q0.004 98.95 0.765 100.788Q1.526 102.625 2.933 104.032Q4.339 105.439 6.177 106.2Q8.015 106.961 10.004 106.961Q11.993 106.961 13.831 106.2Q15.668 105.439 17.075 104.032L96.076 25.031L175.077 104.032Q176.484 105.439 178.322 106.2Q180.159 106.961 182.148 106.961Q184.138 106.961 185.975 106.2Q187.813 105.439 189.219 104.032Q190.626 102.625 191.387 100.788Q192.148 98.95 192.148 96.961Q192.148 94.972 191.387 93.134Q190.626 91.296 189.22 89.89L103.147 3.818Q101.741 2.411 99.903 1.65Q98.065 0.889 96.076 0.889Q94.087 0.889 92.249 1.65Q90.412 2.411 89.005 3.818Z"
|
||||
```
|
||||
|
||||
## Lottie Animation
|
||||
|
||||
Render [Lottie](https://airbnb.io/lottie/) animations using Skia's [Skottie](https://skia.org/docs/user/modules/skottie/) module.
|
||||
|
||||
### Load Animation
|
||||
|
||||
```js
|
||||
const { LottieAnimation } = require('@napi-rs/canvas')
|
||||
|
||||
// Load from file
|
||||
const animation = LottieAnimation.loadFromFile('animation.json')
|
||||
|
||||
// Load from JSON string with resource path for external assets
|
||||
const animation = LottieAnimation.loadFromData(jsonString, {
|
||||
resourcePath: '/path/to/assets',
|
||||
})
|
||||
```
|
||||
|
||||
### Animation Properties
|
||||
|
||||
```js
|
||||
animation.duration // Total duration in seconds
|
||||
animation.fps // Frames per second
|
||||
animation.frames // Total frame count
|
||||
animation.width // Animation width
|
||||
animation.height // Animation height
|
||||
animation.version // Lottie format version
|
||||
```
|
||||
|
||||
### Playback Control
|
||||
|
||||
```js
|
||||
animation.seekFrame(30) // Seek to frame 30
|
||||
animation.seek(1.5) // Seek to 1.5 seconds
|
||||
```
|
||||
|
||||
### Render to Canvas
|
||||
|
||||
```js
|
||||
const { createCanvas, LottieAnimation } = require('@napi-rs/canvas')
|
||||
|
||||
const animation = LottieAnimation.loadFromFile('animation.json')
|
||||
const canvas = createCanvas(animation.width, animation.height)
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// Render at original size
|
||||
animation.render(ctx)
|
||||
|
||||
// Render with custom destination rect
|
||||
animation.render(ctx, { x: 0, y: 0, width: 800, height: 600 })
|
||||
```
|
||||
|
||||
### Supported Features
|
||||
|
||||
- **Embedded images** - Base64-encoded images (`data:image/png;base64,...`)
|
||||
- **Embedded fonts** - Vector glyph paths for text rendering without system fonts
|
||||
- **External assets** - Load images from `resourcePath` directory
|
||||
- **dotLottie format** - Extract `.lottie` ZIP files at runtime (see example)
|
||||
|
||||
### Example: Encode Lottie to Video
|
||||
|
||||
See [`example/lottie-to-video.ts`](./example/lottie-to-video.ts) for encoding Lottie animations to MP4 using [`@napi-rs/webcodecs`](https://github.com/Brooooooklyn/webcodecs-node).
|
||||
|
||||
```js
|
||||
import { createCanvas, LottieAnimation } from '@napi-rs/canvas'
|
||||
import {
|
||||
VideoEncoder,
|
||||
VideoFrame,
|
||||
Mp4Muxer,
|
||||
type EncodedVideoChunk,
|
||||
type EncodedVideoChunkMetadata,
|
||||
} from '@napi-rs/webcodecs'
|
||||
|
||||
const animation = LottieAnimation.loadFromFile('animation.json')
|
||||
const canvas = createCanvas(animation.width, animation.height)
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
for (let frame = 0; frame < animation.frames; frame++) {
|
||||
animation.seekFrame(frame)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
animation.render(ctx)
|
||||
// Encode frame to video...
|
||||
}
|
||||
```
|
||||
|
||||
# [Example](./example/tiger.js)
|
||||
|
||||
> The tiger.json was serialized from [gojs/samples/tiger](https://github.com/NorthwoodsSoftware/GoJS/blob/master/samples/tiger.html)
|
||||
|
||||
<img width="500" src="example/tiger.png">
|
||||
|
||||
```shell
|
||||
node example/anime-girl.js
|
||||
```
|
||||
|
||||
| SVG | PNG |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| <img width="500" src="example/anime-girl.svg"><br/>[CC-BY-SA 3.0](https://creativecommons.org/licenses/by/3.0) by [Niabot](https://commons.wikimedia.org/wiki/User:Niabot) | <img width="500" src="example/anime-girl.png"><br/>[CC-BY-SA 3.0](https://creativecommons.org/licenses/by/3.0) by [Niabot](https://commons.wikimedia.org/wiki/User:Niabot) |
|
||||
|
||||
# Building
|
||||
|
||||
## Build skia from source
|
||||
|
||||
You can build this project from source, the system requirements are here: https://skia.org/docs/user/build
|
||||
|
||||
```sh
|
||||
# Clone the code:
|
||||
$ git clone --recurse-submodules https://github.com/Brooooooklyn/canvas.git
|
||||
$ cd canvas
|
||||
|
||||
# Build Skia:
|
||||
$ node scripts/build-skia.js
|
||||
|
||||
# Install NPM packages, build the Node.js addon:
|
||||
$ npm install -g yarn
|
||||
$ yarn install --mode=skip-build # Here are modules that are used for benchmarking and are hard to install, you can skip it by specifying `--mode=skip-build`
|
||||
$ sudo dnf install clang # https://fedora.pkgs.org/34/fedora-x86_64/clang-12.0.0-0.3.rc1.fc34.x86_64.rpm.html
|
||||
$ yarn build
|
||||
|
||||
# All done! Run test cases or examples now:
|
||||
$ yarn test
|
||||
$ node example/tiger.js
|
||||
```
|
||||
|
||||
## Pull pre-build skia binary from GitHub
|
||||
|
||||
You can pull skia pre-build binaries if you just care the `Rust` part:
|
||||
|
||||
```sh
|
||||
# Clone the code:
|
||||
$ git clone --recurse-submodules https://github.com/Brooooooklyn/canvas.git
|
||||
$ cd canvas
|
||||
|
||||
# Download Skia binaries:
|
||||
# It will pull the binaries match the git hash in `./skia` submodule
|
||||
$ node scripts/release-skia-binary.mjs --download
|
||||
|
||||
# Install NPM packages, build the Node.js addon:
|
||||
$ npm install -g yarn
|
||||
$ yarn install --mode=skip-build
|
||||
$ sudo dnf install clang # https://fedora.pkgs.org/34/fedora-x86_64/clang-12.0.0-0.3.rc1.fc34.x86_64.rpm.html
|
||||
$ yarn build
|
||||
|
||||
# All done! Run test cases or examples now:
|
||||
$ yarn test
|
||||
$ node example/tiger.js
|
||||
```
|
||||
+873
@@ -0,0 +1,873 @@
|
||||
const { inspect } = require('util')
|
||||
/*
|
||||
* vendored in order to fix its dependence on the window global [cds 2020/08/04]
|
||||
* otherwise unchanged from https://github.com/jarek-foksa/geometry-polyfill/tree/f36bbc8f4bc43539d980687904ce46c8e915543d
|
||||
*/
|
||||
|
||||
// @info
|
||||
// DOMPoint polyfill
|
||||
// @src
|
||||
// https://drafts.fxtf.org/geometry/#DOMPoint
|
||||
// https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/geometry/dom_point_read_only.cc
|
||||
class DOMPoint {
|
||||
constructor(x = 0, y = 0, z = 0, w = 1) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
this.z = z
|
||||
this.w = w
|
||||
}
|
||||
|
||||
static fromPoint(otherPoint) {
|
||||
return new DOMPoint(
|
||||
otherPoint.x,
|
||||
otherPoint.y,
|
||||
otherPoint.z !== undefined ? otherPoint.z : 0,
|
||||
otherPoint.w !== undefined ? otherPoint.w : 1,
|
||||
)
|
||||
}
|
||||
|
||||
matrixTransform(matrix) {
|
||||
if ((matrix.is2D || matrix instanceof SVGMatrix) && this.z === 0 && this.w === 1) {
|
||||
return new DOMPoint(
|
||||
this.x * matrix.a + this.y * matrix.c + matrix.e,
|
||||
this.x * matrix.b + this.y * matrix.d + matrix.f,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
} else {
|
||||
return new DOMPoint(
|
||||
this.x * matrix.m11 + this.y * matrix.m21 + this.z * matrix.m31 + this.w * matrix.m41,
|
||||
this.x * matrix.m12 + this.y * matrix.m22 + this.z * matrix.m32 + this.w * matrix.m42,
|
||||
this.x * matrix.m13 + this.y * matrix.m23 + this.z * matrix.m33 + this.w * matrix.m43,
|
||||
this.x * matrix.m14 + this.y * matrix.m24 + this.z * matrix.m34 + this.w * matrix.m44,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
z: this.z,
|
||||
w: this.w,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @info
|
||||
// DOMRect polyfill
|
||||
// @src
|
||||
// https://drafts.fxtf.org/geometry/#DOMRect
|
||||
// https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/geometry/dom_rect_read_only.cc
|
||||
|
||||
class DOMRect {
|
||||
constructor(x = 0, y = 0, width = 0, height = 0) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
|
||||
static fromRect(otherRect) {
|
||||
return new DOMRect(otherRect.x, otherRect.y, otherRect.width, otherRect.height)
|
||||
}
|
||||
|
||||
get top() {
|
||||
return this.y
|
||||
}
|
||||
|
||||
get left() {
|
||||
return this.x
|
||||
}
|
||||
|
||||
get right() {
|
||||
return this.x + this.width
|
||||
}
|
||||
|
||||
get bottom() {
|
||||
return this.y + this.height
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
top: this.top,
|
||||
left: this.left,
|
||||
right: this.right,
|
||||
bottom: this.bottom,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const propertyName of ['top', 'right', 'bottom', 'left']) {
|
||||
const propertyDescriptor = Object.getOwnPropertyDescriptor(DOMRect.prototype, propertyName)
|
||||
propertyDescriptor.enumerable = true
|
||||
Object.defineProperty(DOMRect.prototype, propertyName, propertyDescriptor)
|
||||
}
|
||||
|
||||
// @info
|
||||
// DOMMatrix polyfill (SVG 2)
|
||||
// @src
|
||||
// https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/geometry/dom_matrix_read_only.cc
|
||||
// https://github.com/tocharomera/generativecanvas/blob/master/node-canvas/lib/DOMMatrix.js
|
||||
|
||||
const M11 = 0,
|
||||
M12 = 1,
|
||||
M13 = 2,
|
||||
M14 = 3
|
||||
const M21 = 4,
|
||||
M22 = 5,
|
||||
M23 = 6,
|
||||
M24 = 7
|
||||
const M31 = 8,
|
||||
M32 = 9,
|
||||
M33 = 10,
|
||||
M34 = 11
|
||||
const M41 = 12,
|
||||
M42 = 13,
|
||||
M43 = 14,
|
||||
M44 = 15
|
||||
|
||||
const A = M11,
|
||||
B = M12
|
||||
const C = M21,
|
||||
D = M22
|
||||
const E = M41,
|
||||
F = M42
|
||||
|
||||
const DEGREE_PER_RAD = 180 / Math.PI
|
||||
const RAD_PER_DEGREE = Math.PI / 180
|
||||
|
||||
const VALUES = Symbol('values')
|
||||
const IS_2D = Symbol('is2D')
|
||||
|
||||
function parseMatrix(init) {
|
||||
let parsed = init.replace(/matrix\(/, '').split(/,/, 7)
|
||||
|
||||
if (parsed.length !== 6) {
|
||||
throw new Error(`Failed to parse ${init}`)
|
||||
}
|
||||
|
||||
parsed = parsed.map(parseFloat)
|
||||
|
||||
return [parsed[0], parsed[1], 0, 0, parsed[2], parsed[3], 0, 0, 0, 0, 1, 0, parsed[4], parsed[5], 0, 1]
|
||||
}
|
||||
|
||||
function parseMatrix3d(init) {
|
||||
const parsed = init.replace(/matrix3d\(/, '').split(/,/, 17)
|
||||
|
||||
if (parsed.length !== 16) {
|
||||
throw new Error(`Failed to parse ${init}`)
|
||||
}
|
||||
|
||||
return parsed.map(parseFloat)
|
||||
}
|
||||
|
||||
function parseTransform(tform) {
|
||||
const type = tform.split(/\(/, 1)[0]
|
||||
|
||||
if (type === 'matrix') {
|
||||
return parseMatrix(tform)
|
||||
} else if (type === 'matrix3d') {
|
||||
return parseMatrix3d(tform)
|
||||
} else {
|
||||
throw new Error(`${type} parsing not implemented`)
|
||||
}
|
||||
}
|
||||
|
||||
const setNumber2D = (receiver, index, value) => {
|
||||
if (typeof value !== 'number') {
|
||||
throw new TypeError('Expected number')
|
||||
}
|
||||
|
||||
receiver[VALUES][index] = value
|
||||
}
|
||||
|
||||
const setNumber3D = (receiver, index, value) => {
|
||||
if (typeof value !== 'number') {
|
||||
throw new TypeError('Expected number')
|
||||
}
|
||||
|
||||
if (index === M33 || index === M44) {
|
||||
if (value !== 1) {
|
||||
receiver[IS_2D] = false
|
||||
}
|
||||
} else if (value !== 0) {
|
||||
receiver[IS_2D] = false
|
||||
}
|
||||
|
||||
receiver[VALUES][index] = value
|
||||
}
|
||||
|
||||
const newInstance = (values) => {
|
||||
const instance = Object.create(DOMMatrix.prototype)
|
||||
instance.constructor = DOMMatrix
|
||||
instance[IS_2D] = true
|
||||
instance[VALUES] = values
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
const multiply = (first, second) => {
|
||||
const dest = new Float64Array(16)
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
let sum = 0
|
||||
|
||||
for (let k = 0; k < 4; k++) {
|
||||
sum += first[i * 4 + k] * second[k * 4 + j]
|
||||
}
|
||||
|
||||
dest[i * 4 + j] = sum
|
||||
}
|
||||
}
|
||||
|
||||
return dest
|
||||
}
|
||||
|
||||
class DOMMatrix {
|
||||
get m11() {
|
||||
return this[VALUES][M11]
|
||||
}
|
||||
set m11(value) {
|
||||
setNumber2D(this, M11, value)
|
||||
}
|
||||
get m12() {
|
||||
return this[VALUES][M12]
|
||||
}
|
||||
set m12(value) {
|
||||
setNumber2D(this, M12, value)
|
||||
}
|
||||
get m13() {
|
||||
return this[VALUES][M13]
|
||||
}
|
||||
set m13(value) {
|
||||
setNumber3D(this, M13, value)
|
||||
}
|
||||
get m14() {
|
||||
return this[VALUES][M14]
|
||||
}
|
||||
set m14(value) {
|
||||
setNumber3D(this, M14, value)
|
||||
}
|
||||
get m21() {
|
||||
return this[VALUES][M21]
|
||||
}
|
||||
set m21(value) {
|
||||
setNumber2D(this, M21, value)
|
||||
}
|
||||
get m22() {
|
||||
return this[VALUES][M22]
|
||||
}
|
||||
set m22(value) {
|
||||
setNumber2D(this, M22, value)
|
||||
}
|
||||
get m23() {
|
||||
return this[VALUES][M23]
|
||||
}
|
||||
set m23(value) {
|
||||
setNumber3D(this, M23, value)
|
||||
}
|
||||
get m24() {
|
||||
return this[VALUES][M24]
|
||||
}
|
||||
set m24(value) {
|
||||
setNumber3D(this, M24, value)
|
||||
}
|
||||
get m31() {
|
||||
return this[VALUES][M31]
|
||||
}
|
||||
set m31(value) {
|
||||
setNumber3D(this, M31, value)
|
||||
}
|
||||
get m32() {
|
||||
return this[VALUES][M32]
|
||||
}
|
||||
set m32(value) {
|
||||
setNumber3D(this, M32, value)
|
||||
}
|
||||
get m33() {
|
||||
return this[VALUES][M33]
|
||||
}
|
||||
set m33(value) {
|
||||
setNumber3D(this, M33, value)
|
||||
}
|
||||
get m34() {
|
||||
return this[VALUES][M34]
|
||||
}
|
||||
set m34(value) {
|
||||
setNumber3D(this, M34, value)
|
||||
}
|
||||
get m41() {
|
||||
return this[VALUES][M41]
|
||||
}
|
||||
set m41(value) {
|
||||
setNumber2D(this, M41, value)
|
||||
}
|
||||
get m42() {
|
||||
return this[VALUES][M42]
|
||||
}
|
||||
set m42(value) {
|
||||
setNumber2D(this, M42, value)
|
||||
}
|
||||
get m43() {
|
||||
return this[VALUES][M43]
|
||||
}
|
||||
set m43(value) {
|
||||
setNumber3D(this, M43, value)
|
||||
}
|
||||
get m44() {
|
||||
return this[VALUES][M44]
|
||||
}
|
||||
set m44(value) {
|
||||
setNumber3D(this, M44, value)
|
||||
}
|
||||
|
||||
get a() {
|
||||
return this[VALUES][A]
|
||||
}
|
||||
set a(value) {
|
||||
setNumber2D(this, A, value)
|
||||
}
|
||||
get b() {
|
||||
return this[VALUES][B]
|
||||
}
|
||||
set b(value) {
|
||||
setNumber2D(this, B, value)
|
||||
}
|
||||
get c() {
|
||||
return this[VALUES][C]
|
||||
}
|
||||
set c(value) {
|
||||
setNumber2D(this, C, value)
|
||||
}
|
||||
get d() {
|
||||
return this[VALUES][D]
|
||||
}
|
||||
set d(value) {
|
||||
setNumber2D(this, D, value)
|
||||
}
|
||||
get e() {
|
||||
return this[VALUES][E]
|
||||
}
|
||||
set e(value) {
|
||||
setNumber2D(this, E, value)
|
||||
}
|
||||
get f() {
|
||||
return this[VALUES][F]
|
||||
}
|
||||
set f(value) {
|
||||
setNumber2D(this, F, value)
|
||||
}
|
||||
|
||||
get is2D() {
|
||||
return this[IS_2D]
|
||||
}
|
||||
|
||||
get isIdentity() {
|
||||
const values = this[VALUES]
|
||||
|
||||
return (
|
||||
values[M11] === 1 &&
|
||||
values[M12] === 0 &&
|
||||
values[M13] === 0 &&
|
||||
values[M14] === 0 &&
|
||||
values[M21] === 0 &&
|
||||
values[M22] === 1 &&
|
||||
values[M23] === 0 &&
|
||||
values[M24] === 0 &&
|
||||
values[M31] === 0 &&
|
||||
values[M32] === 0 &&
|
||||
values[M33] === 1 &&
|
||||
values[M34] === 0 &&
|
||||
values[M41] === 0 &&
|
||||
values[M42] === 0 &&
|
||||
values[M43] === 0 &&
|
||||
values[M44] === 1
|
||||
)
|
||||
}
|
||||
|
||||
static fromMatrix(init) {
|
||||
if (init instanceof DOMMatrix) {
|
||||
return new DOMMatrix(init[VALUES])
|
||||
} else if (init instanceof SVGMatrix) {
|
||||
return new DOMMatrix([init.a, init.b, init.c, init.d, init.e, init.f])
|
||||
} else {
|
||||
throw new TypeError('Expected DOMMatrix')
|
||||
}
|
||||
}
|
||||
|
||||
static fromFloat32Array(init) {
|
||||
if (!(init instanceof Float32Array)) throw new TypeError('Expected Float32Array')
|
||||
return new DOMMatrix(init)
|
||||
}
|
||||
|
||||
static fromFloat64Array(init) {
|
||||
if (!(init instanceof Float64Array)) throw new TypeError('Expected Float64Array')
|
||||
return new DOMMatrix(init)
|
||||
}
|
||||
|
||||
// @type
|
||||
// (Float64Array) => void
|
||||
constructor(init) {
|
||||
this[IS_2D] = true
|
||||
|
||||
this[VALUES] = new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1])
|
||||
|
||||
// Parse CSS transformList
|
||||
if (typeof init === 'string') {
|
||||
if (init === '') {
|
||||
return
|
||||
} else {
|
||||
const tforms = init.split(/\)\s+/, 20).map(parseTransform)
|
||||
|
||||
if (tforms.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
init = tforms[0]
|
||||
|
||||
for (let i = 1; i < tforms.length; i++) {
|
||||
init = multiply(tforms[i], init)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let i = 0
|
||||
|
||||
if (init && init.length === 6) {
|
||||
setNumber2D(this, A, init[i++])
|
||||
setNumber2D(this, B, init[i++])
|
||||
setNumber2D(this, C, init[i++])
|
||||
setNumber2D(this, D, init[i++])
|
||||
setNumber2D(this, E, init[i++])
|
||||
setNumber2D(this, F, init[i++])
|
||||
} else if (init && init.length === 16) {
|
||||
setNumber2D(this, M11, init[i++])
|
||||
setNumber2D(this, M12, init[i++])
|
||||
setNumber3D(this, M13, init[i++])
|
||||
setNumber3D(this, M14, init[i++])
|
||||
setNumber2D(this, M21, init[i++])
|
||||
setNumber2D(this, M22, init[i++])
|
||||
setNumber3D(this, M23, init[i++])
|
||||
setNumber3D(this, M24, init[i++])
|
||||
setNumber3D(this, M31, init[i++])
|
||||
setNumber3D(this, M32, init[i++])
|
||||
setNumber3D(this, M33, init[i++])
|
||||
setNumber3D(this, M34, init[i++])
|
||||
setNumber2D(this, M41, init[i++])
|
||||
setNumber2D(this, M42, init[i++])
|
||||
setNumber3D(this, M43, init[i++])
|
||||
setNumber3D(this, M44, init[i])
|
||||
} else if (init !== undefined) {
|
||||
throw new TypeError('Expected string or array.')
|
||||
}
|
||||
}
|
||||
|
||||
dump() {
|
||||
const mat = this[VALUES]
|
||||
console.info([mat.slice(0, 4), mat.slice(4, 8), mat.slice(8, 12), mat.slice(12, 16)])
|
||||
}
|
||||
|
||||
[inspect.custom](depth) {
|
||||
if (depth < 0) return '[DOMMatrix]'
|
||||
|
||||
const { a, b, c, d, e, f, is2D, isIdentity } = this
|
||||
if (this.is2D) {
|
||||
return `DOMMatrix ${inspect({ a, b, c, d, e, f, is2D, isIdentity }, { colors: true })}`
|
||||
} else {
|
||||
const { m11, m12, m13, m14, m21, m22, m23, m24, m31, m32, m33, m34, m41, m42, m43, m44, is2D, isIdentity } = this
|
||||
return `DOMMatrix ${inspect(
|
||||
{
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
d,
|
||||
e,
|
||||
f,
|
||||
m11,
|
||||
m12,
|
||||
m13,
|
||||
m14,
|
||||
m21,
|
||||
m22,
|
||||
m23,
|
||||
m24,
|
||||
m31,
|
||||
m32,
|
||||
m33,
|
||||
m34,
|
||||
m41,
|
||||
m42,
|
||||
m43,
|
||||
m44,
|
||||
is2D,
|
||||
isIdentity,
|
||||
},
|
||||
{ colors: true },
|
||||
)}`
|
||||
}
|
||||
}
|
||||
|
||||
multiply(other) {
|
||||
return newInstance(this[VALUES]).multiplySelf(other)
|
||||
}
|
||||
|
||||
multiplySelf(other) {
|
||||
this[VALUES] = multiply(other[VALUES], this[VALUES])
|
||||
|
||||
if (!other.is2D) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
preMultiplySelf(other) {
|
||||
this[VALUES] = multiply(this[VALUES], other[VALUES])
|
||||
|
||||
if (!other.is2D) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
translate(tx, ty, tz) {
|
||||
return newInstance(this[VALUES]).translateSelf(tx, ty, tz)
|
||||
}
|
||||
|
||||
translateSelf(tx = 0, ty = 0, tz = 0) {
|
||||
this[VALUES] = multiply([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, tx, ty, tz, 1], this[VALUES])
|
||||
|
||||
if (tz !== 0) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
scale(scaleX, scaleY, scaleZ, originX, originY, originZ) {
|
||||
return newInstance(this[VALUES]).scaleSelf(scaleX, scaleY, scaleZ, originX, originY, originZ)
|
||||
}
|
||||
|
||||
scale3d(scale, originX, originY, originZ) {
|
||||
return newInstance(this[VALUES]).scale3dSelf(scale, originX, originY, originZ)
|
||||
}
|
||||
|
||||
scale3dSelf(scale, originX, originY, originZ) {
|
||||
return this.scaleSelf(scale, scale, scale, originX, originY, originZ)
|
||||
}
|
||||
|
||||
scaleSelf(scaleX, scaleY, scaleZ, originX, originY, originZ) {
|
||||
// Not redundant with translate's checks because we need to negate the values later.
|
||||
if (typeof originX !== 'number') originX = 0
|
||||
if (typeof originY !== 'number') originY = 0
|
||||
if (typeof originZ !== 'number') originZ = 0
|
||||
|
||||
this.translateSelf(originX, originY, originZ)
|
||||
|
||||
if (typeof scaleX !== 'number') scaleX = 1
|
||||
if (typeof scaleY !== 'number') scaleY = scaleX
|
||||
if (typeof scaleZ !== 'number') scaleZ = 1
|
||||
|
||||
this[VALUES] = multiply([scaleX, 0, 0, 0, 0, scaleY, 0, 0, 0, 0, scaleZ, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
this.translateSelf(-originX, -originY, -originZ)
|
||||
|
||||
if (scaleZ !== 1 || originZ !== 0) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
rotateFromVector(x, y) {
|
||||
return newInstance(this[VALUES]).rotateFromVectorSelf(x, y)
|
||||
}
|
||||
|
||||
rotateFromVectorSelf(x = 0, y = 0) {
|
||||
const theta = x === 0 && y === 0 ? 0 : Math.atan2(y, x) * DEGREE_PER_RAD
|
||||
return this.rotateSelf(theta)
|
||||
}
|
||||
|
||||
rotate(rotX, rotY, rotZ) {
|
||||
return newInstance(this[VALUES]).rotateSelf(rotX, rotY, rotZ)
|
||||
}
|
||||
|
||||
rotateSelf(rotX, rotY, rotZ) {
|
||||
if (rotY === undefined && rotZ === undefined) {
|
||||
rotZ = rotX
|
||||
rotX = rotY = 0
|
||||
}
|
||||
|
||||
if (typeof rotY !== 'number') rotY = 0
|
||||
if (typeof rotZ !== 'number') rotZ = 0
|
||||
|
||||
if (rotX !== 0 || rotY !== 0) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
rotX *= RAD_PER_DEGREE
|
||||
rotY *= RAD_PER_DEGREE
|
||||
rotZ *= RAD_PER_DEGREE
|
||||
|
||||
let c = Math.cos(rotZ)
|
||||
let s = Math.sin(rotZ)
|
||||
|
||||
this[VALUES] = multiply([c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
c = Math.cos(rotY)
|
||||
s = Math.sin(rotY)
|
||||
|
||||
this[VALUES] = multiply([c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
c = Math.cos(rotX)
|
||||
s = Math.sin(rotX)
|
||||
|
||||
this[VALUES] = multiply([1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
rotateAxisAngle(x, y, z, angle) {
|
||||
return newInstance(this[VALUES]).rotateAxisAngleSelf(x, y, z, angle)
|
||||
}
|
||||
|
||||
rotateAxisAngleSelf(x = 0, y = 0, z = 0, angle = 0) {
|
||||
const length = Math.sqrt(x * x + y * y + z * z)
|
||||
|
||||
if (length === 0) {
|
||||
return this
|
||||
}
|
||||
|
||||
if (length !== 1) {
|
||||
x /= length
|
||||
y /= length
|
||||
z /= length
|
||||
}
|
||||
|
||||
angle *= RAD_PER_DEGREE
|
||||
|
||||
const c = Math.cos(angle)
|
||||
const s = Math.sin(angle)
|
||||
const t = 1 - c
|
||||
const tx = t * x
|
||||
const ty = t * y
|
||||
|
||||
this[VALUES] = multiply(
|
||||
[
|
||||
tx * x + c,
|
||||
tx * y + s * z,
|
||||
tx * z - s * y,
|
||||
0,
|
||||
tx * y - s * z,
|
||||
ty * y + c,
|
||||
ty * z + s * x,
|
||||
0,
|
||||
tx * z + s * y,
|
||||
ty * z - s * x,
|
||||
t * z * z + c,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
],
|
||||
this[VALUES],
|
||||
)
|
||||
|
||||
if (x !== 0 || y !== 0) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
skewX(sx) {
|
||||
return newInstance(this[VALUES]).skewXSelf(sx)
|
||||
}
|
||||
|
||||
skewXSelf(sx) {
|
||||
if (typeof sx !== 'number') {
|
||||
return this
|
||||
}
|
||||
|
||||
const t = Math.tan(sx * RAD_PER_DEGREE)
|
||||
|
||||
this[VALUES] = multiply([1, 0, 0, 0, t, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
skewY(sy) {
|
||||
return newInstance(this[VALUES]).skewYSelf(sy)
|
||||
}
|
||||
|
||||
skewYSelf(sy) {
|
||||
if (typeof sy !== 'number') {
|
||||
return this
|
||||
}
|
||||
|
||||
const t = Math.tan(sy * RAD_PER_DEGREE)
|
||||
|
||||
this[VALUES] = multiply([1, t, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
flipX() {
|
||||
return newInstance(multiply([-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES]))
|
||||
}
|
||||
|
||||
flipY() {
|
||||
return newInstance(multiply([1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES]))
|
||||
}
|
||||
|
||||
inverse() {
|
||||
return newInstance(this[VALUES].slice()).invertSelf()
|
||||
}
|
||||
|
||||
invertSelf() {
|
||||
if (this[IS_2D]) {
|
||||
const det = this[VALUES][A] * this[VALUES][D] - this[VALUES][B] * this[VALUES][C]
|
||||
|
||||
// Invertable
|
||||
if (det !== 0) {
|
||||
const newA = this[VALUES][D] / det
|
||||
const newB = -this[VALUES][B] / det
|
||||
const newC = -this[VALUES][C] / det
|
||||
const newD = this[VALUES][A] / det
|
||||
const newE = (this[VALUES][C] * this[VALUES][F] - this[VALUES][D] * this[VALUES][E]) / det
|
||||
const newF = (this[VALUES][B] * this[VALUES][E] - this[VALUES][A] * this[VALUES][F]) / det
|
||||
|
||||
this.a = newA
|
||||
this.b = newB
|
||||
this.c = newC
|
||||
this.d = newD
|
||||
this.e = newE
|
||||
this.f = newF
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
// Not invertable
|
||||
else {
|
||||
this[IS_2D] = false
|
||||
|
||||
this[VALUES] = [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN]
|
||||
|
||||
return this
|
||||
}
|
||||
} else {
|
||||
throw new Error('3D matrix inversion is not implemented.')
|
||||
}
|
||||
}
|
||||
|
||||
setMatrixValue(transformList) {
|
||||
const temp = new DOMMatrix(transformList)
|
||||
|
||||
this[VALUES] = temp[VALUES]
|
||||
this[IS_2D] = temp[IS_2D]
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
transformPoint(point) {
|
||||
const x = point.x || 0
|
||||
const y = point.y || 0
|
||||
const z = point.z || 0
|
||||
const w = point.w || 1
|
||||
|
||||
const values = this[VALUES]
|
||||
|
||||
const nx = values[M11] * x + values[M21] * y + values[M31] * z + values[M41] * w
|
||||
const ny = values[M12] * x + values[M22] * y + values[M32] * z + values[M42] * w
|
||||
const nz = values[M13] * x + values[M23] * y + values[M33] * z + values[M43] * w
|
||||
const nw = values[M14] * x + values[M24] * y + values[M34] * z + values[M44] * w
|
||||
|
||||
return new DOMPoint(nx, ny, nz, nw)
|
||||
}
|
||||
|
||||
toFloat32Array() {
|
||||
return Float32Array.from(this[VALUES])
|
||||
}
|
||||
|
||||
toFloat64Array() {
|
||||
return this[VALUES].slice(0)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
a: this.a,
|
||||
b: this.b,
|
||||
c: this.c,
|
||||
d: this.d,
|
||||
e: this.e,
|
||||
f: this.f,
|
||||
m11: this.m11,
|
||||
m12: this.m12,
|
||||
m13: this.m13,
|
||||
m14: this.m14,
|
||||
m21: this.m21,
|
||||
m22: this.m22,
|
||||
m23: this.m23,
|
||||
m24: this.m24,
|
||||
m31: this.m31,
|
||||
m32: this.m32,
|
||||
m33: this.m33,
|
||||
m34: this.m34,
|
||||
m41: this.m41,
|
||||
m42: this.m42,
|
||||
m43: this.m43,
|
||||
m44: this.m44,
|
||||
is2D: this.is2D,
|
||||
isIdentity: this.isIdentity,
|
||||
}
|
||||
}
|
||||
|
||||
toString() {
|
||||
if (this.is2D) {
|
||||
return `matrix(${this.a}, ${this.b}, ${this.c}, ${this.d}, ${this.e}, ${this.f})`
|
||||
} else {
|
||||
return `matrix3d(${this[VALUES].join(', ')})`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const propertyName of [
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
'e',
|
||||
'f',
|
||||
'm11',
|
||||
'm12',
|
||||
'm13',
|
||||
'm14',
|
||||
'm21',
|
||||
'm22',
|
||||
'm23',
|
||||
'm24',
|
||||
'm31',
|
||||
'm32',
|
||||
'm33',
|
||||
'm34',
|
||||
'm41',
|
||||
'm42',
|
||||
'm43',
|
||||
'm44',
|
||||
'is2D',
|
||||
'isIdentity',
|
||||
]) {
|
||||
const propertyDescriptor = Object.getOwnPropertyDescriptor(DOMMatrix.prototype, propertyName)
|
||||
propertyDescriptor.enumerable = true
|
||||
Object.defineProperty(DOMMatrix.prototype, propertyName, propertyDescriptor)
|
||||
}
|
||||
|
||||
module.exports = { DOMPoint, DOMMatrix, DOMRect }
|
||||
+1097
File diff suppressed because it is too large.
Load diff
+196
@@ -0,0 +1,196 @@
|
||||
const { platform, homedir } = require('os')
|
||||
const { join } = require('path')
|
||||
|
||||
const {
|
||||
clearAllCache,
|
||||
CanvasRenderingContext2D,
|
||||
CanvasElement,
|
||||
SVGCanvas,
|
||||
Path: Path2D,
|
||||
ImageData,
|
||||
Image,
|
||||
FontKey,
|
||||
GlobalFonts,
|
||||
PathOp,
|
||||
FillType,
|
||||
StrokeJoin,
|
||||
StrokeCap,
|
||||
convertSVGTextToPath,
|
||||
PdfDocument,
|
||||
GifEncoder,
|
||||
GifDisposal,
|
||||
LottieAnimation,
|
||||
} = require('./js-binding')
|
||||
|
||||
const { DOMPoint, DOMMatrix, DOMRect } = require('./geometry')
|
||||
|
||||
const loadImage = require('./load-image')
|
||||
|
||||
// Add Symbol.dispose support for GifEncoder (ECMAScript 2024 Explicit Resource Management)
|
||||
if (GifEncoder && typeof Symbol.dispose !== 'undefined') {
|
||||
GifEncoder.prototype[Symbol.dispose] = function () {
|
||||
this.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const SvgExportFlag = {
|
||||
ConvertTextToPaths: 0x01,
|
||||
NoPrettyXML: 0x02,
|
||||
RelativePathEncoding: 0x04,
|
||||
}
|
||||
|
||||
if (!('families' in GlobalFonts)) {
|
||||
Object.defineProperty(GlobalFonts, 'families', {
|
||||
get: function () {
|
||||
return JSON.parse(GlobalFonts.getFamilies().toString())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!('has' in GlobalFonts)) {
|
||||
Object.defineProperty(GlobalFonts, 'has', {
|
||||
value: function has(name) {
|
||||
return !!JSON.parse(GlobalFonts.getFamilies().toString()).find(({ family }) => family === name)
|
||||
},
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
})
|
||||
}
|
||||
|
||||
const _toBlob = CanvasElement.prototype.toBlob
|
||||
const _convertToBlob = CanvasElement.prototype.convertToBlob
|
||||
if ('Blob' in globalThis) {
|
||||
CanvasElement.prototype.toBlob = function toBlob(callback, mimeType, quality) {
|
||||
_toBlob.call(
|
||||
this,
|
||||
function (/** @type {Uint8Array} */ imageBuffer) {
|
||||
const blob = new Blob([imageBuffer.buffer], { type: mimeType })
|
||||
callback(blob)
|
||||
},
|
||||
mimeType,
|
||||
quality,
|
||||
)
|
||||
}
|
||||
CanvasElement.prototype.convertToBlob = function convertToBlob(options) {
|
||||
return _convertToBlob.call(this, options).then((/** @type {Uint8Array} */ imageBuffer) => {
|
||||
const blob = new Blob([imageBuffer.buffer], { type: options?.mime || 'image/png' })
|
||||
return blob
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
CanvasElement.prototype.toBlob = function toBlob(callback, mimeType, quality) {
|
||||
callback(null)
|
||||
}
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
CanvasElement.prototype.convertToBlob = function convertToBlob(options) {
|
||||
return Promise.reject(new Error('Blob is not supported in this environment'))
|
||||
}
|
||||
}
|
||||
|
||||
const _getTransform = CanvasRenderingContext2D.prototype.getTransform
|
||||
|
||||
CanvasRenderingContext2D.prototype.getTransform = function getTransform() {
|
||||
const transform = _getTransform.apply(this, arguments)
|
||||
// monkey patched, skip
|
||||
if (transform instanceof DOMMatrix) {
|
||||
return transform
|
||||
}
|
||||
const { a, b, c, d, e, f } = transform
|
||||
return new DOMMatrix([a, b, c, d, e, f])
|
||||
}
|
||||
|
||||
// Workaround for webpack bundling issue with drawImage
|
||||
// Store the original drawImage method
|
||||
const _drawImage = CanvasRenderingContext2D.prototype.drawImage
|
||||
|
||||
// Override drawImage to ensure proper type recognition in bundled environments
|
||||
CanvasRenderingContext2D.prototype.drawImage = function drawImage(image, ...args) {
|
||||
// If the image is a Canvas-like object but not recognized due to bundling,
|
||||
// we need to ensure it's properly identified
|
||||
if (image && typeof image === 'object') {
|
||||
// First check if it's a wrapped canvas object
|
||||
if (image.canvas instanceof CanvasElement || image.canvas instanceof SVGCanvas) {
|
||||
image = image.canvas
|
||||
} else if (image._canvas instanceof CanvasElement || image._canvas instanceof SVGCanvas) {
|
||||
image = image._canvas
|
||||
}
|
||||
// Then check if it's a Canvas-like object by checking for getContext method
|
||||
else if (typeof image.getContext === 'function' && image.width && image.height) {
|
||||
// If it has canvas properties but isn't recognized as CanvasElement or SVGCanvas,
|
||||
// try to correct the prototype chain
|
||||
if (!(image instanceof CanvasElement) && !(image instanceof SVGCanvas)) {
|
||||
// Try to create a proper CanvasElement from the canvas-like object
|
||||
// This helps when webpack has transformed the prototype chain
|
||||
Object.setPrototypeOf(image, CanvasElement.prototype)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call the original drawImage with the potentially corrected image
|
||||
return _drawImage.apply(this, [image, ...args])
|
||||
}
|
||||
|
||||
function createCanvas(width, height, flag) {
|
||||
const isSvgBackend = typeof flag !== 'undefined'
|
||||
return isSvgBackend ? new SVGCanvas(width, height, flag) : new CanvasElement(width, height)
|
||||
}
|
||||
|
||||
class Canvas {
|
||||
constructor(width, height, flag) {
|
||||
return createCanvas(width, height, flag)
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance](instance) {
|
||||
return instance instanceof CanvasElement || instance instanceof SVGCanvas
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.DISABLE_SYSTEM_FONTS_LOAD) {
|
||||
GlobalFonts.loadSystemFonts()
|
||||
const platformName = platform()
|
||||
const homedirPath = homedir()
|
||||
switch (platformName) {
|
||||
case 'win32':
|
||||
GlobalFonts.loadFontsFromDir(join(homedirPath, 'AppData', 'Local', 'Microsoft', 'Windows', 'Fonts'))
|
||||
break
|
||||
case 'darwin':
|
||||
GlobalFonts.loadFontsFromDir(join(homedirPath, 'Library', 'Fonts'))
|
||||
break
|
||||
case 'linux':
|
||||
GlobalFonts.loadFontsFromDir(join('usr', 'local', 'share', 'fonts'))
|
||||
GlobalFonts.loadFontsFromDir(join(homedirPath, '.fonts'))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clearAllCache,
|
||||
Canvas,
|
||||
createCanvas,
|
||||
Path2D,
|
||||
ImageData,
|
||||
Image,
|
||||
PathOp,
|
||||
FillType,
|
||||
StrokeCap,
|
||||
StrokeJoin,
|
||||
SvgExportFlag,
|
||||
GlobalFonts: GlobalFonts,
|
||||
convertSVGTextToPath,
|
||||
DOMPoint,
|
||||
DOMMatrix,
|
||||
DOMRect,
|
||||
loadImage,
|
||||
FontKey,
|
||||
// Export these for better webpack compatibility
|
||||
CanvasElement,
|
||||
SVGCanvas,
|
||||
PDFDocument: PdfDocument,
|
||||
// GIF encoding
|
||||
GifEncoder,
|
||||
GifDisposal,
|
||||
// Lottie animation
|
||||
LottieAnimation,
|
||||
}
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
// prettier-ignore
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
/* auto-generated by NAPI-RS */
|
||||
|
||||
const { readFileSync } = require('fs')
|
||||
let nativeBinding = null
|
||||
const loadErrors = []
|
||||
|
||||
const isMusl = () => {
|
||||
let musl = false
|
||||
if (process.platform === 'linux') {
|
||||
musl = isMuslFromFilesystem()
|
||||
if (musl === null) {
|
||||
musl = isMuslFromReport()
|
||||
}
|
||||
if (musl === null) {
|
||||
musl = isMuslFromChildProcess()
|
||||
}
|
||||
}
|
||||
return musl
|
||||
}
|
||||
|
||||
const isFileMusl = (f) => f.includes('libc.musl-') || f.includes('ld-musl-')
|
||||
|
||||
const isMuslFromFilesystem = () => {
|
||||
try {
|
||||
return readFileSync('/usr/bin/ldd', 'utf-8').includes('musl')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const isMuslFromReport = () => {
|
||||
let report = null
|
||||
if (typeof process.report?.getReport === 'function') {
|
||||
process.report.excludeNetwork = true
|
||||
report = process.report.getReport()
|
||||
}
|
||||
if (!report) {
|
||||
return null
|
||||
}
|
||||
if (report.header && report.header.glibcVersionRuntime) {
|
||||
return false
|
||||
}
|
||||
if (Array.isArray(report.sharedObjects)) {
|
||||
if (report.sharedObjects.some(isFileMusl)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const isMuslFromChildProcess = () => {
|
||||
try {
|
||||
return require('child_process').execSync('ldd --version', { encoding: 'utf8' }).includes('musl')
|
||||
} catch (e) {
|
||||
// If we reach this case, we don't know if the system is musl or not, so is better to just fallback to false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function requireNative() {
|
||||
if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) {
|
||||
try {
|
||||
return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH)
|
||||
} catch (err) {
|
||||
loadErrors.push(err)
|
||||
}
|
||||
} else if (process.platform === 'android') {
|
||||
if (process.arch === 'arm64') {
|
||||
try {
|
||||
return require('./skia.android-arm64.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-android-arm64')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else if (process.arch === 'arm') {
|
||||
try {
|
||||
return require('./skia.android-arm-eabi.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-android-arm-eabi')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
loadErrors.push(new Error(`Unsupported architecture on Android ${process.arch}`))
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
if (process.arch === 'x64') {
|
||||
if (
|
||||
process.config?.variables?.shlib_suffix === 'dll.a' ||
|
||||
process.config?.variables?.node_target_type === 'shared_library'
|
||||
) {
|
||||
try {
|
||||
return require('./skia.win32-x64-gnu.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-win32-x64-gnu')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return require('./skia.win32-x64-msvc.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-win32-x64-msvc')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
}
|
||||
} else if (process.arch === 'ia32') {
|
||||
try {
|
||||
return require('./skia.win32-ia32-msvc.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-win32-ia32-msvc')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else if (process.arch === 'arm64') {
|
||||
try {
|
||||
return require('./skia.win32-arm64-msvc.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-win32-arm64-msvc')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
loadErrors.push(new Error(`Unsupported architecture on Windows: ${process.arch}`))
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
try {
|
||||
return require('./skia.darwin-universal.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-darwin-universal')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
if (process.arch === 'x64') {
|
||||
try {
|
||||
return require('./skia.darwin-x64.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-darwin-x64')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else if (process.arch === 'arm64') {
|
||||
try {
|
||||
return require('./skia.darwin-arm64.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-darwin-arm64')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
loadErrors.push(new Error(`Unsupported architecture on macOS: ${process.arch}`))
|
||||
}
|
||||
} else if (process.platform === 'freebsd') {
|
||||
if (process.arch === 'x64') {
|
||||
try {
|
||||
return require('./skia.freebsd-x64.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-freebsd-x64')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else if (process.arch === 'arm64') {
|
||||
try {
|
||||
return require('./skia.freebsd-arm64.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-freebsd-arm64')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
loadErrors.push(new Error(`Unsupported architecture on FreeBSD: ${process.arch}`))
|
||||
}
|
||||
} else if (process.platform === 'linux') {
|
||||
if (process.arch === 'x64') {
|
||||
if (isMusl()) {
|
||||
try {
|
||||
return require('./skia.linux-x64-musl.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-x64-musl')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return require('./skia.linux-x64-gnu.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-x64-gnu')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
}
|
||||
} else if (process.arch === 'arm64') {
|
||||
if (isMusl()) {
|
||||
try {
|
||||
return require('./skia.linux-arm64-musl.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-arm64-musl')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return require('./skia.linux-arm64-gnu.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-arm64-gnu')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
}
|
||||
} else if (process.arch === 'arm') {
|
||||
if (isMusl()) {
|
||||
try {
|
||||
return require('./skia.linux-arm-musleabihf.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-arm-musleabihf')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return require('./skia.linux-arm-gnueabihf.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-arm-gnueabihf')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
}
|
||||
} else if (process.arch === 'loong64') {
|
||||
if (isMusl()) {
|
||||
try {
|
||||
return require('./skia.linux-loong64-musl.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-loong64-musl')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return require('./skia.linux-loong64-gnu.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-loong64-gnu')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
}
|
||||
} else if (process.arch === 'riscv64') {
|
||||
if (isMusl()) {
|
||||
try {
|
||||
return require('./skia.linux-riscv64-musl.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-riscv64-musl')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return require('./skia.linux-riscv64-gnu.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-riscv64-gnu')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
}
|
||||
} else if (process.arch === 'ppc64') {
|
||||
try {
|
||||
return require('./skia.linux-ppc64-gnu.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-ppc64-gnu')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else if (process.arch === 's390x') {
|
||||
try {
|
||||
return require('./skia.linux-s390x-gnu.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-s390x-gnu')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
loadErrors.push(new Error(`Unsupported architecture on Linux: ${process.arch}`))
|
||||
}
|
||||
} else if (process.platform === 'openharmony') {
|
||||
if (process.arch === 'arm64') {
|
||||
try {
|
||||
return require('./skia.openharmony-arm64.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-openharmony-arm64')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else if (process.arch === 'x64') {
|
||||
try {
|
||||
return require('./skia.openharmony-x64.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-openharmony-x64')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else if (process.arch === 'arm') {
|
||||
try {
|
||||
return require('./skia.openharmony-arm.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
const binding = require('@napi-rs/canvas-openharmony-arm')
|
||||
return binding
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
loadErrors.push(new Error(`Unsupported architecture on OpenHarmony: ${process.arch}`))
|
||||
}
|
||||
} else {
|
||||
loadErrors.push(new Error(`Unsupported OS: ${process.platform}, architecture: ${process.arch}`))
|
||||
}
|
||||
}
|
||||
|
||||
nativeBinding = requireNative()
|
||||
|
||||
if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) {
|
||||
let wasiBinding = null
|
||||
let wasiBindingError = null
|
||||
try {
|
||||
wasiBinding = require('./skia.wasi.cjs')
|
||||
nativeBinding = wasiBinding
|
||||
} catch (err) {
|
||||
if (process.env.NAPI_RS_FORCE_WASI) {
|
||||
wasiBindingError = err
|
||||
}
|
||||
}
|
||||
if (!nativeBinding) {
|
||||
try {
|
||||
wasiBinding = require('@napi-rs/canvas-wasm32-wasi')
|
||||
nativeBinding = wasiBinding
|
||||
} catch (err) {
|
||||
if (process.env.NAPI_RS_FORCE_WASI) {
|
||||
wasiBindingError.cause = err
|
||||
loadErrors.push(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!nativeBinding) {
|
||||
if (loadErrors.length > 0) {
|
||||
throw new Error(
|
||||
`Cannot find native binding. ` +
|
||||
`npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` +
|
||||
'Please try `npm i` again after removing both package-lock.json and node_modules directory.',
|
||||
{
|
||||
cause: loadErrors.reduce((err, cur) => {
|
||||
cur.cause = err
|
||||
return cur
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
module.exports = nativeBinding
|
||||
module.exports.GlobalFonts = nativeBinding.GlobalFonts
|
||||
module.exports.CanvasElement = nativeBinding.CanvasElement
|
||||
module.exports.CanvasGradient = nativeBinding.CanvasGradient
|
||||
module.exports.CanvasPattern = nativeBinding.CanvasPattern
|
||||
module.exports.CanvasRenderingContext2D = nativeBinding.CanvasRenderingContext2D
|
||||
module.exports.FontKey = nativeBinding.FontKey
|
||||
module.exports.Image = nativeBinding.Image
|
||||
module.exports.ImageData = nativeBinding.ImageData
|
||||
module.exports.Path = nativeBinding.Path
|
||||
module.exports.PdfDocument = nativeBinding.PdfDocument
|
||||
module.exports.SVGCanvas = nativeBinding.SVGCanvas
|
||||
module.exports.ChromaSubsampling = nativeBinding.ChromaSubsampling
|
||||
module.exports.clearAllCache = nativeBinding.clearAllCache
|
||||
module.exports.convertSVGTextToPath = nativeBinding.convertSVGTextToPath
|
||||
module.exports.FillType = nativeBinding.FillType
|
||||
module.exports.PathOp = nativeBinding.PathOp
|
||||
module.exports.StrokeCap = nativeBinding.StrokeCap
|
||||
module.exports.StrokeJoin = nativeBinding.StrokeJoin
|
||||
module.exports.SvgExportFlag = nativeBinding.SvgExportFlag
|
||||
module.exports.GifEncoder = nativeBinding.GifEncoder
|
||||
module.exports.GifDisposal = nativeBinding.GifDisposal
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
const fs = require('fs')
|
||||
const { Readable } = require('stream')
|
||||
const { URL } = require('url')
|
||||
|
||||
const { Image } = require('./js-binding')
|
||||
|
||||
let http, https
|
||||
|
||||
const MAX_REDIRECTS = 20
|
||||
const REDIRECT_STATUSES = new Set([301, 302])
|
||||
|
||||
/**
|
||||
* Loads the given source into canvas Image
|
||||
* @param {string|URL|Image|Buffer} source The image source to be loaded
|
||||
* @param {object} options Options passed to the loader
|
||||
*/
|
||||
module.exports = async function loadImage(source, options = {}) {
|
||||
// use the same buffer without copying if the source is a buffer
|
||||
if (Buffer.isBuffer(source) || source instanceof Uint8Array) return createImage(source, options.alt)
|
||||
// load readable stream as image
|
||||
if (source instanceof Readable) return createImage(await consumeStream(source), options.alt)
|
||||
// construct a Uint8Array if the source is ArrayBuffer or SharedArrayBuffer
|
||||
if (source instanceof ArrayBuffer || source instanceof SharedArrayBuffer)
|
||||
return createImage(new Uint8Array(source), options.alt)
|
||||
// construct a buffer if the source is buffer-like
|
||||
if (isBufferLike(source)) return createImage(Buffer.from(source), options.alt)
|
||||
// if the source is Image instance, copy the image src to new image
|
||||
if (source instanceof Image) return createImage(source.src, options.alt)
|
||||
// if source is string and in data uri format, construct image using data uri
|
||||
if (typeof source === 'string' && source.trimStart().startsWith('data:')) {
|
||||
const commaIdx = source.indexOf(',')
|
||||
const encoding = source.lastIndexOf('base64', commaIdx) < 0 ? 'utf-8' : 'base64'
|
||||
const data = Buffer.from(source.slice(commaIdx + 1), encoding)
|
||||
// Empty payload would silently complete on Image (HTML spec) and the loadImage
|
||||
// promise would never settle; reject upfront instead.
|
||||
// See https://github.com/Brooooooklyn/canvas/issues/1255
|
||||
if (data.length === 0) throw new Error(`Invalid data URI: empty payload in ${source.slice(0, 64)}`)
|
||||
return createImage(data, options.alt)
|
||||
}
|
||||
// if source is a string or URL instance
|
||||
if (typeof source === 'string') {
|
||||
// if the source exists as a file, construct image from that file
|
||||
if (!source.startsWith('http') && !source.startsWith('https') && (await exists(source))) {
|
||||
return createImage(source, options.alt)
|
||||
} else {
|
||||
// the source is a remote url here
|
||||
source = new URL(source)
|
||||
// attempt to download the remote source and construct image
|
||||
const data = await new Promise((resolve, reject) =>
|
||||
makeRequest(
|
||||
source,
|
||||
resolve,
|
||||
reject,
|
||||
typeof options.maxRedirects === 'number' && options.maxRedirects >= 0 ? options.maxRedirects : MAX_REDIRECTS,
|
||||
options.requestOptions,
|
||||
),
|
||||
)
|
||||
return createImage(data, options.alt)
|
||||
}
|
||||
}
|
||||
|
||||
if (source instanceof URL) {
|
||||
if (source.protocol === 'file:') {
|
||||
// remove the leading slash on windows
|
||||
return createImage(process.platform === 'win32' ? source.pathname.substring(1) : source.pathname, options.alt)
|
||||
} else {
|
||||
const data = await new Promise((resolve, reject) =>
|
||||
makeRequest(
|
||||
source,
|
||||
resolve,
|
||||
reject,
|
||||
typeof options.maxRedirects === 'number' && options.maxRedirects >= 0 ? options.maxRedirects : MAX_REDIRECTS,
|
||||
options.requestOptions,
|
||||
),
|
||||
)
|
||||
return createImage(data, options.alt)
|
||||
}
|
||||
}
|
||||
|
||||
// throw error as don't support that source
|
||||
throw new TypeError('unsupported image source')
|
||||
}
|
||||
|
||||
function makeRequest(url, resolve, reject, redirectCount, requestOptions) {
|
||||
const isHttps = url.protocol === 'https:'
|
||||
// lazy load the lib
|
||||
const lib = isHttps ? (!https ? (https = require('https')) : https) : !http ? (http = require('http')) : http
|
||||
|
||||
lib
|
||||
.get(url.toString(), requestOptions || {}, (res) => {
|
||||
try {
|
||||
const shouldRedirect = REDIRECT_STATUSES.has(res.statusCode) && typeof res.headers.location === 'string'
|
||||
if (shouldRedirect && redirectCount > 0)
|
||||
return makeRequest(
|
||||
new URL(res.headers.location, url.origin),
|
||||
resolve,
|
||||
reject,
|
||||
redirectCount - 1,
|
||||
requestOptions,
|
||||
)
|
||||
if (typeof res.statusCode === 'number' && (res.statusCode < 200 || res.statusCode >= 300)) {
|
||||
return reject(new Error(`remote source rejected with status code ${res.statusCode}`))
|
||||
}
|
||||
|
||||
consumeStream(res).then(resolve, reject)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
.on('error', reject)
|
||||
}
|
||||
|
||||
// use stream/consumers in the future?
|
||||
function consumeStream(res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = []
|
||||
|
||||
res.on('data', (chunk) => chunks.push(chunk))
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
res.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function createImage(src, alt) {
|
||||
// Empty Buffer / Uint8Array: Image follows the HTML spec (silent completion,
|
||||
// no events) so the promise below would hang. Reject upfront.
|
||||
// See https://github.com/Brooooooklyn/canvas/issues/1255
|
||||
if ((Buffer.isBuffer(src) || src instanceof Uint8Array) && src.length === 0) {
|
||||
throw new Error('loadImage: empty image data')
|
||||
}
|
||||
|
||||
const image = new Image()
|
||||
if (typeof alt === 'string') image.alt = alt
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
image.onload = () => {
|
||||
// Wait for bitmap decode before resolving. SVG sources fire onload
|
||||
// synchronously inside the src setter, where napi >= 3.12 rejects
|
||||
// reentrant &mut borrows of the Image, so decode on a microtask.
|
||||
Promise.resolve()
|
||||
.then(() => image.decode())
|
||||
.then(() => resolve(image), reject)
|
||||
}
|
||||
image.onerror = (e) => reject(e)
|
||||
image.src = src
|
||||
})
|
||||
}
|
||||
|
||||
function isBufferLike(src) {
|
||||
return (src && src.type === 'Buffer') || Array.isArray(src)
|
||||
}
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await fs.promises.access(path, fs.constants.F_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* node-canvas compatibility layer for @napi-rs/canvas.
|
||||
*
|
||||
* Provides a drop-in replacement API matching the `canvas` (node-canvas) npm package.
|
||||
* Quality values for JPEG/WebP use the node-canvas 0-1 scale and are automatically
|
||||
* converted to @napi-rs/canvas's 0-100 scale internally.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Drop-in replacement: change this import
|
||||
* // import { createCanvas, registerFont, loadImage } from 'canvas'
|
||||
* // to this:
|
||||
* import { createCanvas, registerFont, loadImage } from '@napi-rs/canvas/node-canvas'
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
// Re-export types from @napi-rs/canvas that are API-compatible
|
||||
import {
|
||||
Image as NapiImage,
|
||||
ImageData as NapiImageData,
|
||||
Path2D as NapiPath2D,
|
||||
DOMPoint as NapiDOMPoint,
|
||||
DOMMatrix as NapiDOMMatrix,
|
||||
DOMRect as NapiDOMRect,
|
||||
SKRSContext2D,
|
||||
Canvas as NapiCanvas,
|
||||
SvgCanvas,
|
||||
SvgExportFlag,
|
||||
IGlobalFonts,
|
||||
PDFDocument,
|
||||
} from './index'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config interfaces (node-canvas conventions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PngConfig {
|
||||
/** ZLIB compression level (0-9). Accepted for compatibility; Skia uses its own encoder. */
|
||||
compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||
/** PNG filter flags. Accepted for compatibility; Skia uses its own encoder. */
|
||||
filters?: number
|
||||
/** Palette for indexed PNGs. Not supported by Skia backend. */
|
||||
palette?: Uint8ClampedArray
|
||||
/** Background color index for indexed PNGs. Not supported by Skia backend. */
|
||||
backgroundIndex?: number
|
||||
/** Pixels per inch. Accepted for compatibility. */
|
||||
resolution?: number
|
||||
}
|
||||
|
||||
export interface JpegConfig {
|
||||
/** Quality between 0 and 1. Defaults to 0.75. Converted to 0-100 for Skia. */
|
||||
quality?: number
|
||||
/** Progressive encoding. Accepted for compatibility; not supported by Skia backend. */
|
||||
progressive?: boolean
|
||||
/** 2x2 chroma subsampling. Accepted for compatibility; not supported by Skia backend. */
|
||||
chromaSubsampling?: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canvas class (with node-canvas compatible methods)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Canvas extends Omit<NapiCanvas, 'toBuffer' | 'toDataURL'> {
|
||||
/**
|
||||
* Encode the canvas as a PNG and return a Node.js Readable stream.
|
||||
* @param config PNG encoding options (accepted for compatibility)
|
||||
*/
|
||||
createPNGStream(config?: PngConfig): PNGStream
|
||||
|
||||
/**
|
||||
* Encode the canvas as a JPEG and return a Node.js Readable stream.
|
||||
* @param config JPEG encoding options. Quality is 0-1 (default 0.75).
|
||||
*/
|
||||
createJPEGStream(config?: JpegConfig): JPEGStream
|
||||
|
||||
// --- toBuffer overloads (node-canvas conventions) ---
|
||||
|
||||
/** Encode as PNG (default). */
|
||||
toBuffer(): Buffer
|
||||
toBuffer(mimeType: 'image/png', config?: PngConfig): Buffer
|
||||
/** Encode as JPEG. Quality in config is 0-1 (default 0.75). */
|
||||
toBuffer(mimeType: 'image/jpeg', config?: JpegConfig): Buffer
|
||||
/** Get raw unencoded pixel data. */
|
||||
toBuffer(mimeType: 'raw'): Buffer
|
||||
|
||||
/** Async: encode as PNG (default) via callback. */
|
||||
toBuffer(cb: (err: Error | null, result: Buffer) => void): void
|
||||
toBuffer(cb: (err: Error | null, result: Buffer) => void, mimeType: 'image/png', config?: PngConfig): void
|
||||
/** Async: encode as JPEG via callback. Quality in config is 0-1 (default 0.75). */
|
||||
toBuffer(cb: (err: Error | null, result: Buffer) => void, mimeType: 'image/jpeg', config?: JpegConfig): void
|
||||
|
||||
// --- toDataURL overloads (node-canvas conventions) ---
|
||||
|
||||
/** Encode as PNG data URL (default). */
|
||||
toDataURL(): string
|
||||
toDataURL(mimeType: 'image/png'): string
|
||||
/** Encode as JPEG data URL. Quality is 0-1. */
|
||||
toDataURL(mimeType: 'image/jpeg', quality?: number): string
|
||||
/** Async: encode as data URL via callback. */
|
||||
toDataURL(cb: (err: Error | null, result: string) => void): void
|
||||
toDataURL(mimeType: 'image/png', cb: (err: Error | null, result: string) => void): void
|
||||
toDataURL(mimeType: 'image/jpeg', cb: (err: Error | null, result: string) => void): void
|
||||
toDataURL(mimeType: 'image/jpeg', quality: number, cb: (err: Error | null, result: string) => void): void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream classes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Readable stream that emits PNG-encoded data. */
|
||||
export class PNGStream extends Readable {}
|
||||
|
||||
/** Readable stream that emits JPEG-encoded data. */
|
||||
export class JPEGStream extends Readable {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new canvas with node-canvas compatible API.
|
||||
* Canvas instances created through this function have `createPNGStream()`,
|
||||
* `createJPEGStream()`, and node-canvas compatible `toBuffer()` overloads.
|
||||
*
|
||||
* When `type` is `'svg'`, returns a `SvgCanvas` which produces vector output
|
||||
* via `getContent()` instead of raster encoding methods.
|
||||
*
|
||||
* @param width Canvas width in pixels
|
||||
* @param height Canvas height in pixels
|
||||
* @param type Canvas type: 'image' (default) or 'svg'
|
||||
*/
|
||||
export function createCanvas(width: number, height: number, type: 'svg'): SvgCanvas
|
||||
export function createCanvas(width: number, height: number, type?: 'image'): Canvas
|
||||
|
||||
/**
|
||||
* Create an ImageData instance.
|
||||
* @param data Pixel data array
|
||||
* @param width Width in pixels
|
||||
* @param height Height in pixels (calculated from data length if omitted)
|
||||
*/
|
||||
export function createImageData(data: Uint8ClampedArray, width: number, height?: number): NapiImageData
|
||||
export function createImageData(width: number, height: number): NapiImageData
|
||||
|
||||
/**
|
||||
* Load an image from a file path, URL, Buffer, or other source.
|
||||
* Returns a Promise that resolves to an Image instance.
|
||||
*/
|
||||
export function loadImage(
|
||||
source: string | URL | Buffer | ArrayBufferLike | Uint8Array | NapiImage | import('stream').Readable,
|
||||
options?: { alt?: string; maxRedirects?: number; requestOptions?: import('http').RequestOptions },
|
||||
): Promise<NapiImage>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font registration (node-canvas convention)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register a font file for use in canvas text rendering.
|
||||
* Compatible with node-canvas's `registerFont()`.
|
||||
*
|
||||
* Note: @napi-rs/canvas auto-detects weight and style from font file metadata
|
||||
* (matching browser behavior). The `weight` and `style` properties in fontFace
|
||||
* are accepted for API compatibility.
|
||||
*
|
||||
* @param path Absolute path to the font file (.ttf, .otf, etc.)
|
||||
* @param fontFace Font face properties matching CSS @font-face descriptors
|
||||
*/
|
||||
export function registerFont(path: string, fontFace: { family: string; weight?: string; style?: string }): void
|
||||
|
||||
/**
|
||||
* Deregister all previously registered fonts.
|
||||
* Compatible with node-canvas's `deregisterAllFonts()`.
|
||||
*/
|
||||
export function deregisterAllFonts(): void
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-exported classes & values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Canvas is exported both as a type (the compat interface above) and as a
|
||||
// value (constructor that returns compat Canvas instances at runtime).
|
||||
export declare const Canvas: {
|
||||
new (width: number, height: number): Canvas
|
||||
new (width: number, height: number, flag: SvgExportFlag): SvgCanvas
|
||||
prototype: NapiCanvas
|
||||
}
|
||||
|
||||
export { NapiImage as Image }
|
||||
export { NapiImageData as ImageData }
|
||||
export { NapiPath2D as Path2D }
|
||||
export { NapiDOMPoint as DOMPoint }
|
||||
export { NapiDOMMatrix as DOMMatrix }
|
||||
export { NapiDOMRect as DOMRect }
|
||||
|
||||
// SKRSContext2D is an interface in index.d.ts but a class value at runtime.
|
||||
// Declare as const to export the value for instanceof checks and re-export the type.
|
||||
export type CanvasRenderingContext2D = SKRSContext2D
|
||||
export declare const CanvasRenderingContext2D: { prototype: SKRSContext2D }
|
||||
/** Legacy alias for CanvasRenderingContext2D. */
|
||||
export type Context2d = SKRSContext2D
|
||||
export declare const Context2d: { prototype: SKRSContext2D }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// @napi-rs/canvas extras (not part of node-canvas, but useful)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { IGlobalFonts }
|
||||
export declare const GlobalFonts: IGlobalFonts & {
|
||||
/** Reload system fonts. Available at runtime but not declared in IGlobalFonts. */
|
||||
loadSystemFonts(): number
|
||||
}
|
||||
export { PDFDocument }
|
||||
export { NapiCanvas as CanvasElement }
|
||||
export { SvgCanvas as SVGCanvas }
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
'use strict'
|
||||
|
||||
const { Readable } = require('node:stream')
|
||||
|
||||
const {
|
||||
createCanvas: _createCanvas,
|
||||
Canvas,
|
||||
CanvasElement,
|
||||
SVGCanvas,
|
||||
GlobalFonts,
|
||||
Image,
|
||||
ImageData,
|
||||
Path2D,
|
||||
DOMPoint,
|
||||
DOMMatrix,
|
||||
DOMRect,
|
||||
loadImage,
|
||||
PDFDocument,
|
||||
SvgExportFlag,
|
||||
} = require('./index.js')
|
||||
|
||||
// CanvasRenderingContext2D is not re-exported by index.js, grab from native bindings
|
||||
const { CanvasRenderingContext2D } = require('./js-binding')
|
||||
|
||||
// node-canvas defaults JPEG quality to 0.75 across all paths.
|
||||
// @napi-rs/canvas native default is 0.92 (matching Blink/Chrome).
|
||||
const NODE_CANVAS_DEFAULT_QUALITY = 0.75
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream classes (node-canvas returns Node.js Readable streams)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class PNGStream extends Readable {
|
||||
constructor(canvas, options) {
|
||||
super()
|
||||
this._canvas = canvas
|
||||
this._options = options || {}
|
||||
this._done = false
|
||||
}
|
||||
|
||||
_read() {
|
||||
if (this._done) return
|
||||
this._done = true
|
||||
this._canvas
|
||||
.encode('png')
|
||||
.then((buf) => {
|
||||
this.push(buf)
|
||||
this.push(null)
|
||||
})
|
||||
.catch((err) => {
|
||||
this.destroy(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class JPEGStream extends Readable {
|
||||
constructor(canvas, options) {
|
||||
super()
|
||||
this._canvas = canvas
|
||||
const opts = options || {}
|
||||
// node-canvas quality: 0-1 (default 0.75), encode() expects 0-100
|
||||
this._quality = Math.round((opts.quality != null ? opts.quality : NODE_CANVAS_DEFAULT_QUALITY) * 100)
|
||||
this._done = false
|
||||
}
|
||||
|
||||
_read() {
|
||||
if (this._done) return
|
||||
this._done = true
|
||||
this._canvas
|
||||
.encode('jpeg', this._quality)
|
||||
.then((buf) => {
|
||||
this.push(buf)
|
||||
this.push(null)
|
||||
})
|
||||
.catch((err) => {
|
||||
this.destroy(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quality normalization helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MIME_FORMAT_MAP = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpeg',
|
||||
'image/webp': 'webp',
|
||||
'image/avif': 'avif',
|
||||
'image/gif': 'gif',
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract quality from a node-canvas config object or number.
|
||||
* Returns the raw 0-1 quality value, defaulting to NODE_CANVAS_DEFAULT_QUALITY
|
||||
* for JPEG/WebP when not specified.
|
||||
*
|
||||
* @param {string} mime
|
||||
* @param {number|object|undefined} configOrQuality
|
||||
* @returns {number|undefined} 0-1 scale for JPEG/WebP, undefined for other mimes
|
||||
*/
|
||||
function _extractQuality(mime, configOrQuality) {
|
||||
if (mime !== 'image/jpeg' && mime !== 'image/webp') return undefined
|
||||
if (configOrQuality == null) return NODE_CANVAS_DEFAULT_QUALITY
|
||||
if (typeof configOrQuality === 'number') return configOrQuality
|
||||
if (typeof configOrQuality === 'object' && configOrQuality.quality != null) {
|
||||
return configOrQuality.quality
|
||||
}
|
||||
return NODE_CANVAS_DEFAULT_QUALITY
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compat methods added to each canvas instance created via this module
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Keep references to the original prototype methods
|
||||
const _origToBuffer = CanvasElement.prototype.toBuffer
|
||||
const _origToDataURL = CanvasElement.prototype.toDataURL
|
||||
|
||||
function _compatCreatePNGStream(options) {
|
||||
return new PNGStream(this, options)
|
||||
}
|
||||
|
||||
function _compatCreateJPEGStream(options) {
|
||||
return new JPEGStream(this, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* node-canvas compatible toBuffer:
|
||||
* - toBuffer() → PNG (sync)
|
||||
* - toBuffer('image/png', config?) → PNG (sync)
|
||||
* - toBuffer('image/jpeg', config?) → JPEG (sync, quality 0-1)
|
||||
* - toBuffer('raw') → raw pixel data (sync)
|
||||
* - toBuffer(callback) → PNG (async)
|
||||
* - toBuffer(callback, mime, config?) → specified format (async)
|
||||
*/
|
||||
function _compatToBuffer(mimeOrCallback, configOrQuality) {
|
||||
// Callback form: toBuffer(callback) or toBuffer(callback, mime, config)
|
||||
if (typeof mimeOrCallback === 'function') {
|
||||
const callback = mimeOrCallback
|
||||
const mime = typeof configOrQuality === 'string' ? configOrQuality : 'image/png'
|
||||
const config = arguments[2]
|
||||
|
||||
if (mime === 'raw') {
|
||||
let buf
|
||||
try {
|
||||
buf = this.data()
|
||||
} catch (err) {
|
||||
callback(err)
|
||||
return
|
||||
}
|
||||
callback(null, buf)
|
||||
return
|
||||
}
|
||||
|
||||
const format = MIME_FORMAT_MAP[mime]
|
||||
if (!format) {
|
||||
callback(new TypeError(`Unsupported MIME type "${mime}". Supported: ${Object.keys(MIME_FORMAT_MAP).join(', ')}`))
|
||||
return
|
||||
}
|
||||
const q = _extractQuality(mime, config)
|
||||
this.encode(format, q != null ? Math.round(q * 100) : undefined).then(
|
||||
(buf) => callback(null, buf),
|
||||
(err) => callback(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Sync: no args → PNG
|
||||
if (mimeOrCallback === undefined) {
|
||||
return _origToBuffer.call(this, 'image/png')
|
||||
}
|
||||
|
||||
// Sync: raw pixel data
|
||||
if (mimeOrCallback === 'raw') {
|
||||
return this.data()
|
||||
}
|
||||
|
||||
// Sync: extract quality (0-1) and scale to 0-100 for native toBuffer
|
||||
const q = _extractQuality(mimeOrCallback, configOrQuality)
|
||||
return _origToBuffer.call(this, mimeOrCallback, q != null ? Math.round(q * 100) : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* node-canvas compatible toDataURL:
|
||||
* - toDataURL() → PNG data URL (sync)
|
||||
* - toDataURL('image/jpeg', quality) → JPEG data URL, quality 0-1 (sync)
|
||||
* - toDataURL(callback) → PNG data URL (async)
|
||||
* - toDataURL(mime, callback) → data URL (async)
|
||||
* - toDataURL(mime, quality, callback) → data URL with quality (async)
|
||||
*/
|
||||
function _compatToDataURL(mimeOrCallback, qualityOrCallback) {
|
||||
// toDataURL(callback)
|
||||
if (typeof mimeOrCallback === 'function') {
|
||||
this.toDataURLAsync('image/png').then(
|
||||
(url) => mimeOrCallback(null, url),
|
||||
(err) => mimeOrCallback(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
// toDataURL(mime, callback)
|
||||
if (typeof qualityOrCallback === 'function') {
|
||||
this.toDataURLAsync(mimeOrCallback, _extractQuality(mimeOrCallback, undefined)).then(
|
||||
(url) => qualityOrCallback(null, url),
|
||||
(err) => qualityOrCallback(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
// toDataURL(mime, quality, callback)
|
||||
const cb = arguments[2]
|
||||
if (typeof cb === 'function') {
|
||||
// Native toDataURLAsync expects 0-1 (f64) — pass quality through as-is
|
||||
const quality = _extractQuality(mimeOrCallback, qualityOrCallback)
|
||||
this.toDataURLAsync(mimeOrCallback, quality).then(
|
||||
(url) => cb(null, url),
|
||||
(err) => cb(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Sync form: native toDataURL expects 0-1 (f64) — pass quality through as-is
|
||||
const quality = _extractQuality(mimeOrCallback, qualityOrCallback)
|
||||
return _origToDataURL.call(this, mimeOrCallback, quality)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach node-canvas compatible methods to a CanvasElement instance.
|
||||
*/
|
||||
function _addCompatMethods(canvas) {
|
||||
canvas.createPNGStream = _compatCreatePNGStream
|
||||
canvas.createJPEGStream = _compatCreateJPEGStream
|
||||
canvas.toBuffer = _compatToBuffer
|
||||
canvas.toDataURL = _compatToDataURL
|
||||
return canvas
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: registerFont / deregisterAllFonts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Track FontKeys from registerFont() so deregisterAllFonts() can remove
|
||||
// only user-registered fonts (matching node-canvas behavior which leaves
|
||||
// system fonts untouched).
|
||||
const _registeredFontKeys = []
|
||||
|
||||
/**
|
||||
* Register a font file with the specified font face properties.
|
||||
* Compatible with node-canvas's registerFont(path, { family, weight?, style? }).
|
||||
*
|
||||
* Note: @napi-rs/canvas auto-detects weight and style from font file metadata
|
||||
* (matching browser behavior). The weight and style properties in fontFace are
|
||||
* accepted for API compatibility but the actual values are read from the font.
|
||||
*
|
||||
* @param {string} path Absolute path to the font file (.ttf, .otf, etc.)
|
||||
* @param {{ family: string, weight?: string, style?: string }} fontFace
|
||||
*/
|
||||
function registerFont(path, fontFace) {
|
||||
if (!fontFace || typeof fontFace.family !== 'string') {
|
||||
throw new TypeError('registerFont requires a fontFace with a "family" property')
|
||||
}
|
||||
const key = GlobalFonts.registerFromPath(path, fontFace.family)
|
||||
if (!key) {
|
||||
throw new Error(`Failed to register font from "${path}" with family "${fontFace.family}"`)
|
||||
}
|
||||
_registeredFontKeys.push(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deregister all fonts previously registered via registerFont().
|
||||
* Compatible with node-canvas's deregisterAllFonts() which only removes
|
||||
* user-registered fonts and leaves system fonts untouched.
|
||||
*/
|
||||
function deregisterAllFonts() {
|
||||
if (_registeredFontKeys.length > 0) {
|
||||
GlobalFonts.removeBatch(_registeredFontKeys)
|
||||
_registeredFontKeys.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: createCanvas / createImageData
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new canvas instance with node-canvas compatible API.
|
||||
*
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @param {'image'|'svg'} [type='image']
|
||||
* @returns {Canvas}
|
||||
*/
|
||||
function createCanvas(width, height, type) {
|
||||
if (type === 'svg') {
|
||||
// SvgExportFlag enum requires a valid variant; NoPrettyXML (0x02) is the
|
||||
// least impactful on rendering behavior (only affects XML whitespace).
|
||||
return _createCanvas(width, height, SvgExportFlag.NoPrettyXML)
|
||||
}
|
||||
if (type === 'pdf') {
|
||||
throw new Error(
|
||||
'createCanvas with type "pdf" is not supported. Use the PDFDocument class from @napi-rs/canvas directly.',
|
||||
)
|
||||
}
|
||||
if (type != null && type !== 'image') {
|
||||
throw new TypeError(`createCanvas: unknown type "${type}". Supported types: "image", "svg".`)
|
||||
}
|
||||
return _addCompatMethods(_createCanvas(width, height))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ImageData instance.
|
||||
* Compatible with node-canvas's createImageData().
|
||||
*
|
||||
* @param {Uint8ClampedArray|number} dataOrWidth
|
||||
* @param {number} widthOrHeight
|
||||
* @param {number} [height]
|
||||
* @returns {ImageData}
|
||||
*/
|
||||
function createImageData(dataOrWidth, widthOrHeight, height) {
|
||||
if (typeof dataOrWidth === 'number') {
|
||||
return new ImageData(dataOrWidth, widthOrHeight)
|
||||
}
|
||||
if (height != null) {
|
||||
return new ImageData(dataOrWidth, widthOrHeight, height)
|
||||
}
|
||||
return new ImageData(dataOrWidth, widthOrHeight)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canvas constructor wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// In node-canvas, `new Canvas(w, h)` and `createCanvas(w, h)` produce
|
||||
// equivalent canvases. Wrap the native Canvas constructor so that
|
||||
// `new Canvas(w, h)` also gets compat methods, without polluting the
|
||||
// prototype (which would affect @napi-rs/canvas users who don't use
|
||||
// this compat layer).
|
||||
const CompatCanvas = new Proxy(Canvas, {
|
||||
construct(target, args) {
|
||||
const canvas = new target(...args)
|
||||
if (canvas instanceof CanvasElement) {
|
||||
return _addCompatMethods(canvas)
|
||||
}
|
||||
return canvas
|
||||
},
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports (matches node-canvas export shape)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
module.exports = {
|
||||
// Factory functions
|
||||
Canvas: CompatCanvas,
|
||||
createCanvas,
|
||||
createImageData,
|
||||
loadImage,
|
||||
registerFont,
|
||||
deregisterAllFonts,
|
||||
|
||||
// Classes
|
||||
Image,
|
||||
ImageData,
|
||||
CanvasRenderingContext2D,
|
||||
Context2d: CanvasRenderingContext2D,
|
||||
PNGStream,
|
||||
JPEGStream,
|
||||
Path2D,
|
||||
|
||||
// Geometry
|
||||
DOMPoint,
|
||||
DOMMatrix,
|
||||
DOMRect,
|
||||
|
||||
// @napi-rs/canvas extras (available but not part of node-canvas)
|
||||
GlobalFonts,
|
||||
PDFDocument,
|
||||
CanvasElement,
|
||||
SVGCanvas,
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"name": "@napi-rs/canvas",
|
||||
"version": "1.0.10",
|
||||
"description": "Canvas for Node.js with skia backend",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Brooooooklyn/canvas.git"
|
||||
},
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"napi-rs",
|
||||
"NAPI",
|
||||
"N-API",
|
||||
"Rust",
|
||||
"node-addon",
|
||||
"node-addon-api",
|
||||
"canvas",
|
||||
"image",
|
||||
"pdf",
|
||||
"svg",
|
||||
"skia"
|
||||
],
|
||||
"files": [
|
||||
"index.d.ts",
|
||||
"index.js",
|
||||
"geometry.js",
|
||||
"js-binding.js",
|
||||
"load-image.js",
|
||||
"node-canvas.js",
|
||||
"node-canvas.d.ts"
|
||||
],
|
||||
"napi": {
|
||||
"binaryName": "skia",
|
||||
"targets": [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-apple-darwin",
|
||||
"x86_64-pc-windows-msvc",
|
||||
"aarch64-pc-windows-msvc",
|
||||
"armv7-unknown-linux-gnueabihf",
|
||||
"x86_64-unknown-linux-musl",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-musl",
|
||||
"aarch64-apple-darwin",
|
||||
"aarch64-linux-android",
|
||||
"riscv64-unknown-linux-gnu"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"artifacts": "napi artifacts",
|
||||
"bench": "node --import @oxc-node/core/register benchmark/bench.ts",
|
||||
"build": "napi build --platform --release --js js-binding.js",
|
||||
"build:debug": "napi build --platform --js js-binding.js",
|
||||
"example-lottie": "yarn oxnode ./example/lottie-to-video.ts",
|
||||
"format": "run-p format:source format:rs format:toml",
|
||||
"format:rs": "cargo fmt",
|
||||
"format:source": "prettier . -w",
|
||||
"format:toml": "taplo format",
|
||||
"lint": "oxlint",
|
||||
"prepublishOnly": "pinst --disable && napi prepublish -t npm",
|
||||
"postpublish": "pinst --enable",
|
||||
"test:ci": "NODE_OPTIONS='--import=core-js/proposals/iterator-helpers.js' ava -c 1",
|
||||
"test": "NODE_OPTIONS='--import=core-js/proposals/iterator-helpers.js' ava",
|
||||
"e2e": "yarn workspace @napi-rs/canvas-e2e-webpack test",
|
||||
"version": "napi version && conventional-changelog -p angular -i CHANGELOG.md -s && git add ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jimp/core": "^1.6.0",
|
||||
"@jimp/custom": "^0.22.12",
|
||||
"@jimp/jpeg": "^0.22.12",
|
||||
"@jimp/png": "^0.22.12",
|
||||
"@napi-rs/cli": "^3.8.3",
|
||||
"@napi-rs/webcodecs": "^1.1.1",
|
||||
"@node-rs/crc32": "^1.10.6",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@oxc-node/cli": "^0.1.0",
|
||||
"@oxc-node/core": "^0.1.0",
|
||||
"@taplo/cli": "^0.7.0",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/semver": "^7",
|
||||
"ava": "^8.0.0",
|
||||
"canvas": "^3.2.0",
|
||||
"canvaskit-wasm": "^0.42.0",
|
||||
"colorette": "^2.0.20",
|
||||
"conventional-changelog-cli": "^5.0.0",
|
||||
"core-js": "^3.47.0",
|
||||
"echarts": "^6.0.0",
|
||||
"electron": "^44.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"npm-run-all2": "^9.0.0",
|
||||
"oxlint": "^1.34.0",
|
||||
"oxlint-tsgolint": "^7.0.0",
|
||||
"pinst": "^3.0.0",
|
||||
"png.js": "^0.2.1",
|
||||
"prettier": "^3.7.4",
|
||||
"pretty-bytes": "^7.1.0",
|
||||
"semver": "^7.7.3",
|
||||
"skia-canvas": "^3.0.8",
|
||||
"table": "^6.9.0",
|
||||
"tinybench": "^6.0.0",
|
||||
"typescript": "^7.0.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.@(js|ts|tsx|yml|yaml|md|json|html)": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.@(js|ts|tsx)": [
|
||||
"oxlint --fix"
|
||||
]
|
||||
},
|
||||
"ava": {
|
||||
"nodeArguments": [
|
||||
"--import=@oxc-node/core/register",
|
||||
"--import=core-js/proposals/promise-with-resolvers.js"
|
||||
],
|
||||
"extensions": [
|
||||
"ts"
|
||||
],
|
||||
"files": [
|
||||
"__test__/**/*.spec.ts",
|
||||
"scripts/__test__/**/*.spec.ts"
|
||||
],
|
||||
"workerThreads": false,
|
||||
"cache": false,
|
||||
"timeout": "3m",
|
||||
"environmentVariables": {
|
||||
"SWC_NODE_PROJECT": "./tsconfig.json",
|
||||
"NODE_ENV": "ava"
|
||||
}
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 120,
|
||||
"semi": false,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"arrowParens": "always"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"packageManager": "yarn@4.18.1",
|
||||
"dependenciesMeta": {
|
||||
"canvas": {
|
||||
"built": true
|
||||
},
|
||||
"skia-canvas": {
|
||||
"built": true
|
||||
}
|
||||
},
|
||||
"resolutions": {
|
||||
"@napi-rs/canvas": "workspace:"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-darwin-x64": "1.0.10",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.0.10",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.10",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.0.10",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.0.10",
|
||||
"@napi-rs/canvas-android-arm64": "1.0.10",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.10"
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Matteo Collina and Undici contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+806
@@ -0,0 +1,806 @@
|
||||
# undici
|
||||
|
||||
[](https://github.com/nodejs/undici/actions/workflows/nodejs.yml) [](https://github.com/neostandard/neostandard) [](https://badge.fury.io/js/undici) [](https://codecov.io/gh/nodejs/undici)
|
||||
|
||||
An HTTP/1.1 client, written from scratch for Node.js.
|
||||
|
||||
> Undici means eleven in Italian. 1.1 -> 11 -> Eleven -> Undici.
|
||||
It is also a Stranger Things reference.
|
||||
|
||||
## How to get involved
|
||||
|
||||
Have a question about using Undici? Open a [Q&A Discussion](https://github.com/nodejs/undici/discussions/new) or join our official OpenJS [Slack](https://openjs-foundation.slack.com/archives/C01QF9Q31QD) channel.
|
||||
|
||||
Looking to contribute? Start by reading the [contributing guide](./CONTRIBUTING.md)
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
npm i undici
|
||||
```
|
||||
|
||||
## Benchmarks
|
||||
|
||||
The benchmark is a simple getting data [example](https://github.com/nodejs/undici/blob/main/benchmarks/benchmark.js) using
|
||||
50 TCP connections with a pipelining depth of 10 running on Node 24.14.1.
|
||||
|
||||
### HTTP/1.1
|
||||
|
||||
```
|
||||
┌────────────────────────┬─────────┬────────────────────┬────────────┬─────────────────────────┐
|
||||
│ Tests │ Samples │ Result │ Tolerance │ Difference with slowest │
|
||||
├────────────────────────┼─────────┼────────────────────┼────────────┼─────────────────────────┤
|
||||
│ 'node-fetch' │ 50 │ '4711.86 req/sec' │ '± 2.92 %' │ '-' │
|
||||
│ 'undici - fetch' │ 75 │ '5438.50 req/sec' │ '± 2.97 %' │ '+ 15.42 %' │
|
||||
│ 'axios' │ 45 │ '5448.08 req/sec' │ '± 2.98 %' │ '+ 15.62 %' │
|
||||
│ 'request' │ 65 │ '5809.63 req/sec' │ '± 2.90 %' │ '+ 23.30 %' │
|
||||
│ 'http - no keepalive' │ 35 │ '5910.77 req/sec' │ '± 2.87 %' │ '+ 25.44 %' │
|
||||
│ 'got' │ 50 │ '6047.80 req/sec' │ '± 2.91 %' │ '+ 28.35 %' │
|
||||
│ 'superagent' │ 60 │ '7534.53 req/sec' │ '± 2.97 %' │ '+ 59.91 %' │
|
||||
│ 'http - keepalive' │ 75 │ '9343.41 req/sec' │ '± 2.90 %' │ '+ 98.30 %' │
|
||||
│ 'undici - pipeline' │ 65 │ '13470.70 req/sec' │ '± 2.93 %' │ '+ 185.89 %' │
|
||||
│ 'undici - request' │ 80 │ '16850.87 req/sec' │ '± 2.93 %' │ '+ 257.63 %' │
|
||||
│ 'undici - stream' │ 101 │ '18488.56 req/sec' │ '± 3.81 %' │ '+ 292.38 %' │
|
||||
│ 'undici - dispatch' │ 101 │ '20786.44 req/sec' │ '± 3.08 %' │ '+ 341.15 %' │
|
||||
└────────────────────────┴─────────┴────────────────────┴────────────┴─────────────────────────┘
|
||||
```
|
||||
|
||||
### HTTP/1.1 over HTTPS
|
||||
|
||||
Using [benchmark-https.js](https://github.com/nodejs/undici/blob/main/benchmarks/benchmark-https.js) against an h1-over-TLS server (50 connections, pipelining depth 10, Node 24.14.1).
|
||||
|
||||
```
|
||||
┌────────────────────────┬─────────┬───────────────────┬────────────┬─────────────────────────┐
|
||||
│ Tests │ Samples │ Result │ Tolerance │ Difference with slowest │
|
||||
├────────────────────────┼─────────┼───────────────────┼────────────┼─────────────────────────┤
|
||||
│ 'https - no keepalive'│ 10 │ '1358.40 req/sec' │ '± 1.99 %' │ '-' │
|
||||
│ 'undici - fetch' │ 30 │ '3721.76 req/sec' │ '± 2.97 %' │ '+ 173.98 %' │
|
||||
│ 'https - keepalive' │ 35 │ '5633.91 req/sec' │ '± 2.84 %' │ '+ 314.75 %' │
|
||||
│ 'undici - pipeline' │ 15 │ '6254.05 req/sec' │ '± 2.80 %' │ '+ 360.40 %' │
|
||||
│ 'undici - request' │ 25 │ '6669.80 req/sec' │ '± 2.73 %' │ '+ 391.01 %' │
|
||||
│ 'undici - stream' │ 25 │ '7019.04 req/sec' │ '± 2.77 %' │ '+ 416.71 %' │
|
||||
│ 'undici - dispatch' │ 20 │ '7361.85 req/sec' │ '± 2.90 %' │ '+ 441.95 %' │
|
||||
└────────────────────────┴─────────┴───────────────────┴────────────┴─────────────────────────┘
|
||||
```
|
||||
|
||||
### HTTP/2
|
||||
|
||||
Using [benchmark-http2.js](https://github.com/nodejs/undici/blob/main/benchmarks/benchmark-http2.js) against an h2 server (50 connections, pipelining depth 10, Node 24.14.1).
|
||||
|
||||
```
|
||||
┌────────────────────────┬─────────┬───────────────────┬────────────┬─────────────────────────┐
|
||||
│ Tests │ Samples │ Result │ Tolerance │ Difference with slowest │
|
||||
├────────────────────────┼─────────┼───────────────────┼────────────┼─────────────────────────┤
|
||||
│ 'undici - fetch' │ 45 │ '3499.03 req/sec' │ '± 2.93 %' │ '-' │
|
||||
│ 'native - http2' │ 25 │ '4904.58 req/sec' │ '± 2.81 %' │ '+ 40.17 %' │
|
||||
│ 'undici - pipeline' │ 60 │ '5836.82 req/sec' │ '± 2.99 %' │ '+ 66.81 %' │
|
||||
│ 'undici - request' │ 65 │ '6831.25 req/sec' │ '± 2.83 %' │ '+ 95.23 %' │
|
||||
│ 'undici - stream' │ 55 │ '6874.30 req/sec' │ '± 2.91 %' │ '+ 96.46 %' │
|
||||
│ 'undici - dispatch' │ 55 │ '7791.23 req/sec' │ '± 2.96 %' │ '+ 122.67 %' │
|
||||
└────────────────────────┴─────────┴───────────────────┴────────────┴─────────────────────────┘
|
||||
```
|
||||
|
||||
## Undici vs. Fetch
|
||||
|
||||
### Overview
|
||||
|
||||
Node.js includes a built-in `fetch()` implementation powered by undici starting from Node.js v18. However, there are important differences between using the built-in fetch and installing undici as a separate module.
|
||||
|
||||
### Built-in Fetch (Node.js v18+)
|
||||
|
||||
Node.js's built-in fetch is powered by a bundled version of undici:
|
||||
|
||||
```js
|
||||
// Available globally in Node.js v18+
|
||||
const response = await fetch('https://api.example.com/data');
|
||||
const data = await response.json();
|
||||
|
||||
// Check the bundled undici version
|
||||
console.log(process.versions.undici); // e.g., "5.28.4"
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- No additional dependencies required
|
||||
- Works across different JavaScript runtimes
|
||||
- Automatic compression handling (gzip, deflate, br)
|
||||
- Built-in caching support (in development)
|
||||
|
||||
**Cons:**
|
||||
- Limited to the undici version bundled with your Node.js version
|
||||
- Less control over connection pooling and advanced features
|
||||
- Error handling follows Web API standards (errors wrapped in `TypeError`)
|
||||
- Performance overhead due to Web Streams implementation
|
||||
|
||||
### Undici Module
|
||||
|
||||
Installing undici as a separate module gives you access to the latest features and APIs:
|
||||
|
||||
```bash
|
||||
npm install undici
|
||||
```
|
||||
|
||||
```js
|
||||
import { request, fetch, Agent, setGlobalDispatcher } from 'undici';
|
||||
|
||||
// Use undici.request for maximum performance
|
||||
const { statusCode, headers, body } = await request('https://api.example.com/data');
|
||||
const data = await body.json();
|
||||
|
||||
// Or use undici.fetch with custom configuration
|
||||
const agent = new Agent({ keepAliveTimeout: 10000 });
|
||||
setGlobalDispatcher(agent);
|
||||
const response = await fetch('https://api.example.com/data');
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Latest undici features and bug fixes
|
||||
- Access to advanced APIs (`request`, `stream`, `pipeline`)
|
||||
- Fine-grained control over connection pooling
|
||||
- Better error handling with clearer error messages
|
||||
- Superior performance, especially with `undici.request`
|
||||
- HTTP/1.1 pipelining support
|
||||
- Custom interceptors and middleware
|
||||
- Advanced features like `ProxyAgent`, `Socks5Agent`, `MockAgent`
|
||||
|
||||
**Cons:**
|
||||
- Additional dependency to manage
|
||||
- Larger bundle size
|
||||
|
||||
### When to Use Each
|
||||
|
||||
#### Use Built-in Fetch When:
|
||||
- You want zero dependencies
|
||||
- Building isomorphic code that runs in browsers and Node.js
|
||||
- Publishing to npm and want to maximize compatibility with JS runtimes
|
||||
- Simple HTTP requests without advanced configuration
|
||||
- You're publishing to npm and you want to maximize compatiblity
|
||||
- You don't depend on features from a specific version of undici
|
||||
|
||||
#### Use Undici Module When:
|
||||
- You need the latest undici features and performance improvements
|
||||
- You require advanced connection pooling configuration
|
||||
- You need APIs not available in the built-in fetch (`ProxyAgent`, `Socks5Agent`, `MockAgent`, etc.)
|
||||
- Performance is critical (use `undici.request` for maximum speed)
|
||||
- You want better error handling and debugging capabilities
|
||||
- You need HTTP/1.1 pipelining or advanced interceptors
|
||||
- You prefer decoupled protocol and API interfaces
|
||||
|
||||
### Performance Comparison
|
||||
|
||||
Based on benchmarks, here's the typical performance hierarchy:
|
||||
|
||||
1. **`undici.request()`** - Fastest, most efficient
|
||||
2. **`undici.fetch()`** - Good performance, standard compliance
|
||||
3. **Node.js `http`/`https`** - Baseline performance
|
||||
|
||||
### Migration Guide
|
||||
|
||||
If you're currently using built-in fetch and want to migrate to undici:
|
||||
|
||||
```js
|
||||
// Before: Built-in fetch
|
||||
const response = await fetch('https://api.example.com/data');
|
||||
|
||||
// After: Undici fetch (drop-in replacement)
|
||||
import { fetch } from 'undici';
|
||||
const response = await fetch('https://api.example.com/data');
|
||||
|
||||
// Or: Undici request (better performance)
|
||||
import { request } from 'undici';
|
||||
const { statusCode, body } = await request('https://api.example.com/data');
|
||||
const data = await body.json();
|
||||
```
|
||||
|
||||
### Keep `fetch` and `FormData` together
|
||||
|
||||
When you send a `FormData` body, keep `fetch` and `FormData` from the same
|
||||
implementation.
|
||||
|
||||
Use one of these patterns:
|
||||
|
||||
```js
|
||||
// Built-in globals
|
||||
const body = new FormData()
|
||||
body.set('name', 'some')
|
||||
await fetch('https://example.com', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
```
|
||||
|
||||
```js
|
||||
// undici module imports
|
||||
import { fetch, FormData } from 'undici'
|
||||
|
||||
const body = new FormData()
|
||||
body.set('name', 'some')
|
||||
await fetch('https://example.com', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
```
|
||||
|
||||
If you want the installed `undici` package to provide the globals, call
|
||||
`install()` first:
|
||||
|
||||
```js
|
||||
import { install } from 'undici'
|
||||
|
||||
install()
|
||||
|
||||
const body = new FormData()
|
||||
body.set('name', 'some')
|
||||
await fetch('https://example.com', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
```
|
||||
|
||||
`install()` replaces the global `fetch`, `Headers`, `Response`, `Request`, and
|
||||
`FormData` implementations with undici's versions, so they all match. It also
|
||||
installs undici's `WebSocket`, `CloseEvent`, `ErrorEvent`, `MessageEvent`, and
|
||||
`EventSource` globals.
|
||||
|
||||
Avoid mixing a global `FormData` with `undici.fetch()`, or `undici.FormData`
|
||||
with the built-in global `fetch()`.
|
||||
|
||||
### Version Compatibility
|
||||
|
||||
You can check which version of undici is bundled with your Node.js version:
|
||||
|
||||
```js
|
||||
console.log(process.versions.undici);
|
||||
```
|
||||
|
||||
Installing undici as a module allows you to use a newer version than what's bundled with Node.js, giving you access to the latest features and performance improvements.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Request
|
||||
|
||||
```js
|
||||
import { request } from 'undici'
|
||||
|
||||
const {
|
||||
statusCode,
|
||||
headers,
|
||||
trailers,
|
||||
body
|
||||
} = await request('http://localhost:3000/foo')
|
||||
|
||||
console.log('response received', statusCode)
|
||||
console.log('headers', headers)
|
||||
|
||||
for await (const data of body) { console.log('data', data) }
|
||||
|
||||
console.log('trailers', trailers)
|
||||
```
|
||||
|
||||
### Using Cache Interceptor
|
||||
|
||||
Undici provides a powerful HTTP caching interceptor that follows HTTP caching best practices. Here's how to use it:
|
||||
|
||||
```js
|
||||
import { fetch, Agent, interceptors, cacheStores } from 'undici';
|
||||
|
||||
// Create a client with cache interceptor
|
||||
const client = new Agent().compose(interceptors.cache({
|
||||
// Optional: Configure cache store (defaults to MemoryCacheStore)
|
||||
store: new cacheStores.MemoryCacheStore({
|
||||
maxSize: 100 * 1024 * 1024, // 100MB
|
||||
maxCount: 1000,
|
||||
maxEntrySize: 5 * 1024 * 1024 // 5MB
|
||||
}),
|
||||
|
||||
// Optional: Specify which HTTP methods to cache (default: ['GET', 'HEAD'])
|
||||
methods: ['GET', 'HEAD']
|
||||
}));
|
||||
|
||||
// Set the global dispatcher to use our caching client
|
||||
setGlobalDispatcher(client);
|
||||
|
||||
// Now all fetch requests will use the cache
|
||||
async function getData() {
|
||||
const response = await fetch('https://api.example.com/data');
|
||||
// The server should set appropriate Cache-Control headers in the response
|
||||
// which the cache will respect based on the cache policy
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// First request - fetches from origin
|
||||
const data1 = await getData();
|
||||
|
||||
// Second request - served from cache if within max-age
|
||||
const data2 = await getData();
|
||||
```
|
||||
|
||||
#### Key Features:
|
||||
- **Automatic Caching**: Respects `Cache-Control` and `Expires` headers
|
||||
- **Validation**: Supports `ETag` and `Last-Modified` validation
|
||||
- **Storage Options**: In-memory or persistent SQLite storage
|
||||
- **Flexible**: Configure cache size, TTL, and more
|
||||
|
||||
## Global Installation
|
||||
|
||||
Undici provides an `install()` function to add fetch-related and other web API classes to `globalThis`, making them available globally:
|
||||
|
||||
```js
|
||||
import { install } from 'undici'
|
||||
|
||||
// Install undici's global web APIs
|
||||
install()
|
||||
|
||||
// Now you can use fetch classes globally without importing
|
||||
const response = await fetch('https://api.example.com/data')
|
||||
const data = await response.json()
|
||||
|
||||
// All classes are available globally:
|
||||
const headers = new Headers([['content-type', 'application/json']])
|
||||
const request = new Request('https://example.com')
|
||||
const formData = new FormData()
|
||||
const ws = new WebSocket('wss://example.com')
|
||||
const eventSource = new EventSource('https://example.com/events')
|
||||
```
|
||||
|
||||
The `install()` function adds the following classes to `globalThis`:
|
||||
|
||||
- `fetch` - The fetch function
|
||||
- `Headers` - HTTP headers management
|
||||
- `Response` - HTTP response representation
|
||||
- `Request` - HTTP request representation
|
||||
- `FormData` - Form data handling
|
||||
- `WebSocket` - WebSocket client
|
||||
- `CloseEvent`, `ErrorEvent`, `MessageEvent` - WebSocket events
|
||||
- `EventSource` - Server-sent events client
|
||||
|
||||
When you call `install()`, these globals come from the same undici
|
||||
implementation. For example, global `fetch` and global `FormData` will both be
|
||||
undici's versions, and `WebSocket` and `EventSource` will also come from
|
||||
undici, which is the recommended setup if you want to use undici through
|
||||
globals.
|
||||
|
||||
This is useful for:
|
||||
- Polyfilling environments that don't have fetch
|
||||
- Ensuring consistent fetch behavior across different Node.js versions
|
||||
- Making undici's implementations available globally for libraries that expect them
|
||||
|
||||
## Body Mixins
|
||||
|
||||
The `body` mixins are the most common way to format the request/response body. Mixins include:
|
||||
|
||||
- [`.arrayBuffer()`](https://fetch.spec.whatwg.org/#dom-body-arraybuffer)
|
||||
- [`.blob()`](https://fetch.spec.whatwg.org/#dom-body-blob)
|
||||
- [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes)
|
||||
- [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json)
|
||||
- [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text)
|
||||
|
||||
> [!NOTE]
|
||||
> The body returned from `undici.request` does not implement `.formData()`.
|
||||
|
||||
> [!WARNING]
|
||||
> The body mixins `.arrayBuffer()`, `.blob()`, `.bytes()`, `.json()`, `.text()`,
|
||||
> and `.formData()` buffer the entire body in memory before returning. Where
|
||||
> applicable, they also decode or parse the payload and retain that
|
||||
> representation in memory. Calling these methods therefore means trusting that
|
||||
> the response body is small enough to fit in the available memory. Do not use
|
||||
> them for responses from untrusted or user-controlled sources. Instead, consume
|
||||
> the response body as a stream and enforce an application-specific size limit:
|
||||
> use `response.body` for fetch responses or the `body` returned by
|
||||
> `undici.request()`. For streaming decoded text, use `body.textStream()` on a
|
||||
> fetch `Request` or `Response`.
|
||||
|
||||
Example usage:
|
||||
|
||||
```js
|
||||
import { request } from 'undici'
|
||||
|
||||
const {
|
||||
statusCode,
|
||||
headers,
|
||||
trailers,
|
||||
body
|
||||
} = await request('http://localhost:3000/foo')
|
||||
|
||||
console.log('response received', statusCode)
|
||||
console.log('headers', headers)
|
||||
console.log('data', await body.json())
|
||||
console.log('trailers', trailers)
|
||||
```
|
||||
|
||||
_Note: Once a mixin has been called then the body cannot be reused, thus calling additional mixins on `.body`, e.g. `.body.json(); .body.text()` will result in an error `TypeError: unusable` being thrown and returned through the `Promise` rejection._
|
||||
|
||||
Should you need to access the `body` in plain-text after using a mixin, the best practice is to use the `.text()` mixin first and then manually parse the text to the desired format.
|
||||
|
||||
For more information about their behavior, please reference the body mixin from the [Fetch Standard](https://fetch.spec.whatwg.org/#body-mixin).
|
||||
|
||||
## Common API Methods
|
||||
|
||||
This section documents our most commonly used API methods. Additional APIs are documented in their own files within the [docs](./docs/) folder and are accessible via the navigation list on the left side of the docs site.
|
||||
|
||||
For the top-level APIs below, the `url` argument supplies the request origin and
|
||||
path. Do not pass `origin` or `path` in the second `options` argument. The linked
|
||||
`Dispatcher` option types include those fields because dispatcher methods are
|
||||
lower-level APIs that do not receive a separate `url` argument.
|
||||
|
||||
### `undici.request([url, options]): Promise`
|
||||
|
||||
Arguments:
|
||||
|
||||
* **url** `string | URL | UrlObject`
|
||||
* **options** [`RequestOptions`](./docs/docs/api/Dispatcher.md#parameter-requestoptions)
|
||||
* **dispatcher** `Dispatcher` - Default: [getGlobalDispatcher](#undicigetglobaldispatcher)
|
||||
* **method** `String` - Default: `PUT` if `options.body`, otherwise `GET`
|
||||
|
||||
Returns a promise with the result of the `Dispatcher.request` method.
|
||||
|
||||
Calls `options.dispatcher.request(options)`.
|
||||
|
||||
See [Dispatcher.request](./docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback) for more details, and [request examples](./docs/examples/README.md) for examples.
|
||||
|
||||
### `undici.stream([url, options, ]factory): Promise`
|
||||
|
||||
Arguments:
|
||||
|
||||
* **url** `string | URL | UrlObject`
|
||||
* **options** [`StreamOptions`](./docs/docs/api/Dispatcher.md#parameter-streamoptions)
|
||||
* **dispatcher** `Dispatcher` - Default: [getGlobalDispatcher](#undicigetglobaldispatcher)
|
||||
* **method** `String` - Default: `PUT` if `options.body`, otherwise `GET`
|
||||
* **factory** `Dispatcher.stream.factory`
|
||||
|
||||
Returns a promise with the result of the `Dispatcher.stream` method.
|
||||
|
||||
Calls `options.dispatcher.stream(options, factory)`.
|
||||
|
||||
See [Dispatcher.stream](./docs/docs/api/Dispatcher.md#dispatcherstreamoptions-factory-callback) for more details.
|
||||
|
||||
### `undici.pipeline([url, options, ]handler): Duplex`
|
||||
|
||||
Arguments:
|
||||
|
||||
* **url** `string | URL | UrlObject`
|
||||
* **options** [`PipelineOptions`](./docs/docs/api/Dispatcher.md#parameter-pipelineoptions)
|
||||
* **dispatcher** `Dispatcher` - Default: [getGlobalDispatcher](#undicigetglobaldispatcher)
|
||||
* **method** `String` - Default: `PUT` if `options.body`, otherwise `GET`
|
||||
* **handler** `Dispatcher.pipeline.handler`
|
||||
|
||||
Returns: `stream.Duplex`
|
||||
|
||||
Calls `options.dispatch.pipeline(options, handler)`.
|
||||
|
||||
See [Dispatcher.pipeline](./docs/docs/api/Dispatcher.md#dispatcherpipelineoptions-handler) for more details.
|
||||
|
||||
### `undici.connect([url, options]): Promise`
|
||||
|
||||
Starts two-way communications with the requested resource using [HTTP CONNECT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/CONNECT).
|
||||
|
||||
Arguments:
|
||||
|
||||
* **url** `string | URL | UrlObject`
|
||||
* **options** [`ConnectOptions`](./docs/docs/api/Dispatcher.md#parameter-connectoptions)
|
||||
* **dispatcher** `Dispatcher` - Default: [getGlobalDispatcher](#undicigetglobaldispatcher)
|
||||
* **callback** `(err: Error | null, data: ConnectData | null) => void` (optional)
|
||||
|
||||
Returns a promise with the result of the `Dispatcher.connect` method.
|
||||
|
||||
Calls `options.dispatch.connect(options)`.
|
||||
|
||||
See [Dispatcher.connect](./docs/docs/api/Dispatcher.md#dispatcherconnectoptions-callback) for more details.
|
||||
|
||||
### `undici.fetch(input[, init]): Promise`
|
||||
|
||||
Implements [fetch](https://fetch.spec.whatwg.org/#fetch-method).
|
||||
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch
|
||||
* https://fetch.spec.whatwg.org/#fetch-method
|
||||
|
||||
Basic usage example:
|
||||
|
||||
```js
|
||||
import { fetch } from 'undici'
|
||||
|
||||
|
||||
const res = await fetch('https://example.com')
|
||||
const json = await res.json()
|
||||
console.log(json)
|
||||
```
|
||||
|
||||
You can pass an optional dispatcher to `fetch` as:
|
||||
|
||||
```js
|
||||
import { fetch, Agent } from 'undici'
|
||||
|
||||
const res = await fetch('https://example.com', {
|
||||
// Mocks are also supported
|
||||
dispatcher: new Agent({
|
||||
keepAliveTimeout: 10,
|
||||
keepAliveMaxTimeout: 10
|
||||
})
|
||||
})
|
||||
const json = await res.json()
|
||||
console.log(json)
|
||||
```
|
||||
|
||||
#### `request.body`
|
||||
|
||||
A body can be of the following types:
|
||||
|
||||
- ArrayBuffer
|
||||
- ArrayBufferView
|
||||
- AsyncIterables
|
||||
- Blob
|
||||
- Iterables
|
||||
- String
|
||||
- URLSearchParams
|
||||
- FormData
|
||||
|
||||
In this implementation of fetch, ```request.body``` now accepts ```Async Iterables```. It is not present in the [Fetch Standard](https://fetch.spec.whatwg.org).
|
||||
|
||||
```js
|
||||
import { fetch } from 'undici'
|
||||
|
||||
const data = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield 'hello'
|
||||
yield 'world'
|
||||
},
|
||||
}
|
||||
|
||||
await fetch('https://example.com', { body: data, method: 'POST', duplex: 'half' })
|
||||
```
|
||||
|
||||
[FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData) besides text data and buffers can also utilize streams via [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) objects:
|
||||
|
||||
```js
|
||||
import { openAsBlob } from 'node:fs'
|
||||
|
||||
const file = await openAsBlob('./big.csv')
|
||||
const body = new FormData()
|
||||
body.set('file', file, 'big.csv')
|
||||
|
||||
await fetch('http://example.com', { method: 'POST', body })
|
||||
```
|
||||
|
||||
#### `request.duplex`
|
||||
|
||||
- `'half'`
|
||||
|
||||
In this implementation of fetch, `request.duplex` must be set if `request.body` is `ReadableStream` or `Async Iterables`, however, even though the value must be set to `'half'`, it is actually a _full_ duplex. For more detail refer to the [Fetch Standard](https://fetch.spec.whatwg.org/#dom-requestinit-duplex).
|
||||
|
||||
#### `response.body`
|
||||
|
||||
Nodejs has two kinds of streams: [web streams](https://nodejs.org/api/webstreams.html), which follow the API of the WHATWG web standard found in browsers, and an older Node-specific [streams API](https://nodejs.org/api/stream.html). `response.body` returns a readable web stream. If you would prefer to work with a Node stream you can convert a web stream using `.fromWeb()`.
|
||||
|
||||
```js
|
||||
import { fetch } from 'undici'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
const response = await fetch('https://example.com')
|
||||
const readableWebStream = response.body
|
||||
const readableNodeStream = Readable.fromWeb(readableWebStream)
|
||||
```
|
||||
|
||||
## Specification Compliance
|
||||
|
||||
This section documents parts of the [HTTP/1.1](https://www.rfc-editor.org/rfc/rfc9110.html) and [Fetch Standard](https://fetch.spec.whatwg.org) that Undici does
|
||||
not support or does not fully implement.
|
||||
|
||||
#### CORS
|
||||
|
||||
Unlike browsers, Undici does not implement CORS (Cross-Origin Resource Sharing) checks by default. This means:
|
||||
|
||||
- No preflight requests are automatically sent for cross-origin requests
|
||||
- No validation of `Access-Control-Allow-Origin` headers is performed
|
||||
- Requests to any origin are allowed regardless of the source
|
||||
|
||||
This behavior is intentional for server-side environments where CORS restrictions are typically unnecessary. If your application requires CORS-like protections, you will need to implement these checks manually.
|
||||
|
||||
#### Garbage Collection
|
||||
|
||||
* https://fetch.spec.whatwg.org/#garbage-collection
|
||||
|
||||
The [Fetch Standard](https://fetch.spec.whatwg.org) allows users to skip consuming the response body by relying on
|
||||
[garbage collection](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management#garbage_collection) to release connection resources.
|
||||
|
||||
Garbage collection in Node is less aggressive and deterministic
|
||||
(due to the lack of clear idle periods that browsers have through the rendering refresh rate)
|
||||
which means that leaving the release of connection resources to the garbage collector can lead
|
||||
to excessive connection usage, reduced performance (due to less connection re-use), and even
|
||||
stalls or deadlocks when running out of connections.
|
||||
Therefore, __it is important to always either consume or cancel the response body anyway__.
|
||||
|
||||
```js
|
||||
// Do
|
||||
const { body, headers } = await fetch(url);
|
||||
for await (const chunk of body) {
|
||||
// force consumption of body
|
||||
}
|
||||
|
||||
// Do not
|
||||
const { headers } = await fetch(url);
|
||||
```
|
||||
|
||||
However, if you want to get only headers, it might be better to use `HEAD` request method. Usage of this method will obviate the need for consumption or cancelling of the response body. See [MDN - HTTP - HTTP request methods - HEAD](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD) for more details.
|
||||
|
||||
```js
|
||||
const headers = await fetch(url, { method: 'HEAD' })
|
||||
.then(res => res.headers)
|
||||
```
|
||||
|
||||
Note that consuming the response body is _mandatory_ for `request`:
|
||||
|
||||
```js
|
||||
// Do
|
||||
const { body, headers } = await request(url);
|
||||
await body.dump(); // force consumption of body
|
||||
|
||||
// Do not
|
||||
const { headers } = await request(url);
|
||||
```
|
||||
|
||||
#### Forbidden and Safelisted Header Names
|
||||
|
||||
* https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name
|
||||
* https://fetch.spec.whatwg.org/#forbidden-header-name
|
||||
* https://fetch.spec.whatwg.org/#forbidden-response-header-name
|
||||
* https://github.com/wintercg/fetch/issues/6
|
||||
|
||||
The [Fetch Standard](https://fetch.spec.whatwg.org) requires implementations to exclude certain headers from requests and responses. In browser environments, some headers are forbidden so the user agent remains in full control over them. In Undici, these constraints are removed to give more control to the user.
|
||||
|
||||
#### Content-Encoding
|
||||
|
||||
* https://www.rfc-editor.org/rfc/rfc9110#field.content-encoding
|
||||
|
||||
Undici limits the number of `Content-Encoding` layers in a response to **5** to prevent resource exhaustion attacks. If a server responds with more than 5 content-encodings (e.g., `Content-Encoding: gzip, gzip, gzip, gzip, gzip, gzip`), the fetch will be rejected with an error. This limit matches the approach taken by [curl](https://curl.se/docs/CVE-2022-32206.html) and [urllib3](https://github.com/advisories/GHSA-gm62-xv2j-4rw9).
|
||||
|
||||
#### `undici.upgrade([url, options]): Promise`
|
||||
|
||||
Upgrade to a different protocol. See [MDN - HTTP - Protocol upgrade mechanism](https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism) for more details.
|
||||
|
||||
Arguments:
|
||||
|
||||
* **url** `string | URL | UrlObject`
|
||||
* **options** [`UpgradeOptions`](./docs/docs/api/Dispatcher.md#parameter-upgradeoptions)
|
||||
* **dispatcher** `Dispatcher` - Default: [getGlobalDispatcher](#undicigetglobaldispatcher)
|
||||
* **callback** `(error: Error | null, data: UpgradeData) => void` (optional)
|
||||
|
||||
Returns a promise with the result of the `Dispatcher.upgrade` method.
|
||||
|
||||
Calls `options.dispatcher.upgrade(options)`.
|
||||
|
||||
See [Dispatcher.upgrade](./docs/docs/api/Dispatcher.md#dispatcherupgradeoptions-callback) for more details.
|
||||
|
||||
### `undici.setGlobalDispatcher(dispatcher)`
|
||||
|
||||
* dispatcher `Dispatcher`
|
||||
|
||||
Sets the global dispatcher used by Common API Methods. Global dispatcher is shared among compatible undici modules,
|
||||
including undici that is bundled internally with node.js.
|
||||
|
||||
Undici stores this dispatcher under `Symbol.for('undici.globalDispatcher.2')`.
|
||||
|
||||
`setGlobalDispatcher()` also mirrors the configured dispatcher to
|
||||
`Symbol.for('undici.globalDispatcher.1')` using `Dispatcher1Wrapper`, so Node.js built-in `fetch`
|
||||
can keep using the legacy handler contract while Undici uses the new handler API.
|
||||
|
||||
### `undici.getGlobalDispatcher()`
|
||||
|
||||
Gets the global dispatcher used by Common API Methods.
|
||||
|
||||
Returns: `Dispatcher`
|
||||
|
||||
### `undici.setGlobalOrigin(origin)`
|
||||
|
||||
* origin `string | URL | undefined`
|
||||
|
||||
Sets the global origin used in `fetch`.
|
||||
|
||||
If `undefined` is passed, the global origin will be reset. This will cause `Response.redirect`, `new Request()`, and `fetch` to throw an error when a relative path is passed.
|
||||
|
||||
```js
|
||||
setGlobalOrigin('http://localhost:3000')
|
||||
|
||||
const response = await fetch('/api/ping')
|
||||
|
||||
console.log(response.url) // http://localhost:3000/api/ping
|
||||
```
|
||||
|
||||
### `undici.getGlobalOrigin()`
|
||||
|
||||
Gets the global origin used in `fetch`.
|
||||
|
||||
Returns: `URL`
|
||||
|
||||
### `UrlObject`
|
||||
|
||||
* **port** `string | number` (optional)
|
||||
* **path** `string` (optional)
|
||||
* **pathname** `string` (optional)
|
||||
* **hostname** `string` (optional)
|
||||
* **origin** `string` (optional)
|
||||
* **protocol** `string` (optional)
|
||||
* **search** `string` (optional)
|
||||
|
||||
#### Expect
|
||||
|
||||
Undici does not support the `Expect` request header field. The request
|
||||
body is always immediately sent and the `100 Continue` response will be
|
||||
ignored.
|
||||
|
||||
Refs: https://tools.ietf.org/html/rfc7231#section-5.1.1
|
||||
|
||||
#### Pipelining
|
||||
|
||||
Undici will only use pipelining if configured with a `pipelining` factor
|
||||
greater than `1`. Only enable pipelining when the remote server is trusted.
|
||||
Also it is important to pass `blocking: false` to the request options to
|
||||
properly pipeline requests.
|
||||
|
||||
Undici always assumes that connections are persistent and will immediately
|
||||
pipeline requests, without checking whether the connection is persistent.
|
||||
Hence, automatic fallback to HTTP/1.0 or HTTP/1.1 without pipelining is
|
||||
not supported.
|
||||
|
||||
Undici will immediately pipeline when retrying requests after a failed
|
||||
connection. However, Undici will not retry the first remaining requests in
|
||||
the prior pipeline and instead error the corresponding callback/promise/stream.
|
||||
|
||||
Undici will abort all running requests in the pipeline when any of them are
|
||||
aborted.
|
||||
|
||||
* Refs: https://tools.ietf.org/html/rfc2616#section-8.1.2.2
|
||||
* Refs: https://tools.ietf.org/html/rfc7230#section-6.3.2
|
||||
|
||||
#### Manual Redirect
|
||||
|
||||
Since it is not possible to manually follow an HTTP redirect on the server-side,
|
||||
Undici returns the actual response instead of an `opaqueredirect` filtered one
|
||||
when invoked with a `manual` redirect. This aligns `fetch()` with the other
|
||||
implementations in Deno and Cloudflare Workers.
|
||||
|
||||
Refs: https://fetch.spec.whatwg.org/#atomic-http-redirect-handling
|
||||
|
||||
### Workarounds
|
||||
|
||||
#### Network address family autoselection.
|
||||
|
||||
If you experience problem when connecting to a remote server that is resolved by your DNS servers to a IPv6 (AAAA record)
|
||||
first, there are chances that your local router or ISP might have problem connecting to IPv6 networks. In that case
|
||||
undici will throw an error with code `UND_ERR_CONNECT_TIMEOUT`.
|
||||
|
||||
If the target server resolves to both a IPv6 and IPv4 (A records) address and you are using a compatible Node version
|
||||
(18.3.0 and above), you can fix the problem by providing the `autoSelectFamily` option (support by both `undici.request`
|
||||
and `undici.Agent`) which will enable the family autoselection algorithm when establishing the connection.
|
||||
|
||||
## Collaborators
|
||||
|
||||
* [__Daniele Belardi__](https://github.com/dnlup), <https://www.npmjs.com/~dnlup>
|
||||
* [__Ethan Arrowood__](https://github.com/ethan-arrowood), <https://www.npmjs.com/~ethan_arrowood>
|
||||
* [__Matteo Collina__](https://github.com/mcollina), <https://www.npmjs.com/~matteo.collina>
|
||||
* [__Matthew Aitken__](https://github.com/KhafraDev), <https://www.npmjs.com/~khaf>
|
||||
* [__Robert Nagy__](https://github.com/ronag), <https://www.npmjs.com/~ronag>
|
||||
* [__Szymon Marczak__](https://github.com/szmarczak), <https://www.npmjs.com/~szmarczak>
|
||||
|
||||
## Past Collaborators
|
||||
* [__Tomas Della Vedova__](https://github.com/delvedor), <https://www.npmjs.com/~delvedor>
|
||||
|
||||
### Releasers
|
||||
|
||||
* [__Ethan Arrowood__](https://github.com/ethan-arrowood), <https://www.npmjs.com/~ethan_arrowood>
|
||||
* [__Matteo Collina__](https://github.com/mcollina), <https://www.npmjs.com/~matteo.collina>
|
||||
* [__Robert Nagy__](https://github.com/ronag), <https://www.npmjs.com/~ronag>
|
||||
* [__Matthew Aitken__](https://github.com/KhafraDev), <https://www.npmjs.com/~khaf>
|
||||
|
||||
## Long Term Support
|
||||
|
||||
Undici aligns with the Node.js LTS schedule. The following table shows the supported versions:
|
||||
|
||||
| Undici Version | Bundled in Node.js | Node.js Versions Supported | End of Life |
|
||||
|----------------|--------------------|----------------------------|-------------|
|
||||
| 5.x | 18.x | ≥14.0 (tested: 14, 16, 18) | 2024-04-30 |
|
||||
| 6.x | 20.x, 22.x | ≥18.17 (tested: 18, 20, 21, 22) | 2027-04-30 |
|
||||
| 7.x | 24.x | ≥20.18.1 (tested: 20, 22, 24) | 2028-04-30 |
|
||||
| 8.x | 26.x | ≥22.19.0 (tested: 22, 24, 26) | 2029-04-30 |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
# BalancedPool
|
||||
|
||||
<!--introduced_in=v4.8.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/dispatcher/balanced-pool.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A `BalancedPool` distributes requests across a set of {Pool} instances, each
|
||||
connected to a different upstream. It uses a weighted round-robin algorithm:
|
||||
every upstream starts with the same weight, and the weight is adjusted up on
|
||||
successful connections and down on connection errors, so that healthy upstreams
|
||||
receive a larger share of traffic.
|
||||
|
||||
Requests are not guaranteed to be dispatched in the order they are issued.
|
||||
|
||||
```mjs
|
||||
import { BalancedPool } from 'undici'
|
||||
|
||||
const pool = new BalancedPool([
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:3002',
|
||||
])
|
||||
|
||||
const { statusCode, body } = await pool.request({
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
})
|
||||
console.log('response received', statusCode)
|
||||
for await (const chunk of body) {
|
||||
console.log('data', chunk.toString())
|
||||
}
|
||||
```
|
||||
|
||||
## Class: `BalancedPool`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
changes:
|
||||
- version: v4.10.4
|
||||
pr-url: https://github.com/nodejs/undici/pull/1114
|
||||
description: Introduced the `BalancedPool` class.
|
||||
-->
|
||||
|
||||
* Extends: {Dispatcher}
|
||||
|
||||
A pool of {Pool} instances connected to multiple upstreams.
|
||||
|
||||
### `new BalancedPool(upstreams[, options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
changes:
|
||||
- version: v4.15.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/1237
|
||||
description: Added the `factory` option.
|
||||
-->
|
||||
|
||||
* `upstreams` {URL|string|URL[]|string[]} One or more upstream origins. Each
|
||||
value should only include the **protocol, hostname, and port**. **Default:**
|
||||
`[]`.
|
||||
* `options` {BalancedPoolOptions} (optional)
|
||||
|
||||
The options passed to the constructor are forwarded to every {Pool} instance
|
||||
that is created for an upstream.
|
||||
|
||||
#### Parameter: `BalancedPoolOptions`
|
||||
|
||||
Extends: {PoolOptions}
|
||||
|
||||
In addition to all {PoolOptions}, the following options are supported:
|
||||
|
||||
* `factory` {Function} A function used to create the {Pool} instance for each
|
||||
upstream. **Default:** `(origin, opts) => new Pool(origin, opts)`.
|
||||
* `origin` {URL|string} The upstream origin.
|
||||
* `opts` {Object} The options object derived from the constructor options.
|
||||
* Returns: {Dispatcher}
|
||||
* `maxWeightPerServer` {number} The maximum weight any single upstream can
|
||||
reach. Successful connections increase an upstream's weight up to this value.
|
||||
**Default:** `100`.
|
||||
* `errorPenalty` {number} The amount by which an upstream's weight is decreased
|
||||
on a connection error and increased on a successful connection. **Default:**
|
||||
`15`.
|
||||
|
||||
### `balancedPool.addUpstream(upstream)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
-->
|
||||
|
||||
* `upstream` {URL|string} The upstream origin to add. It should only include the
|
||||
**protocol, hostname, and port**.
|
||||
* Returns: {BalancedPool} The `BalancedPool` instance, for chaining.
|
||||
|
||||
Adds an upstream. If an open, non-destroyed upstream with the same origin is
|
||||
already present, the call is a no-op.
|
||||
|
||||
### `balancedPool.removeUpstream(upstream)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
-->
|
||||
|
||||
* `upstream` {URL|string} The upstream origin to remove. It should only include
|
||||
the **protocol, hostname, and port**.
|
||||
* Returns: {BalancedPool} The `BalancedPool` instance, for chaining.
|
||||
|
||||
Removes an upstream that was previously added. If no matching upstream is found,
|
||||
the call is a no-op.
|
||||
|
||||
### `balancedPool.getUpstream(upstream)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
* `upstream` {URL|string} The upstream origin to look up. It should only include
|
||||
the **protocol, hostname, and port**.
|
||||
* Returns: {Pool|undefined} The {Pool} instance managing the given upstream, or
|
||||
`undefined` if it is not present.
|
||||
|
||||
Returns the {Pool} instance for the given upstream origin, if it is open and not
|
||||
destroyed.
|
||||
|
||||
### `balancedPool.upstreams`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
-->
|
||||
|
||||
* Type: {string[]} The origins of the upstreams that are currently open and not
|
||||
destroyed.
|
||||
|
||||
### `balancedPool.closed`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
changes:
|
||||
- version: v4.10.4
|
||||
pr-url: https://github.com/nodejs/undici/pull/1114
|
||||
description: Introduced the `BalancedPool` class.
|
||||
-->
|
||||
|
||||
* Type: {boolean} `true` after `balancedPool.close()` has been called.
|
||||
|
||||
Implements [`client.closed`][].
|
||||
|
||||
### `balancedPool.destroyed`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
changes:
|
||||
- version: v4.10.4
|
||||
pr-url: https://github.com/nodejs/undici/pull/1114
|
||||
description: Introduced the `BalancedPool` class.
|
||||
-->
|
||||
|
||||
* Type: {boolean} `true` after `balancedPool.destroy()` has been called, or after
|
||||
`balancedPool.close()` has been called and the shutdown has completed.
|
||||
|
||||
Implements [`client.destroyed`][].
|
||||
|
||||
### `balancedPool.stats`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
-->
|
||||
|
||||
* Type: {PoolStats}
|
||||
|
||||
Returns a {PoolStats} instance aggregating the statistics of the underlying
|
||||
pools.
|
||||
|
||||
### `balancedPool.close([callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
-->
|
||||
|
||||
Implements [`Dispatcher.close([callback])`][].
|
||||
|
||||
### `balancedPool.destroy([error[, callback]])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
-->
|
||||
|
||||
Implements [`Dispatcher.destroy([error, callback])`][].
|
||||
|
||||
### `balancedPool.connect(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
-->
|
||||
|
||||
See [`Dispatcher.connect(options[, callback])`][].
|
||||
|
||||
### `balancedPool.dispatch(options, handlers)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
changes:
|
||||
- version: v4.10.4
|
||||
pr-url: https://github.com/nodejs/undici/pull/1114
|
||||
description: Introduced the `BalancedPool` class.
|
||||
-->
|
||||
|
||||
Implements [`Dispatcher.dispatch(options, handler)`][].
|
||||
|
||||
### `balancedPool.pipeline(options, handler)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
-->
|
||||
|
||||
See [`Dispatcher.pipeline(options, handler)`][].
|
||||
|
||||
### `balancedPool.request(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
-->
|
||||
|
||||
See [`Dispatcher.request(options[, callback])`][].
|
||||
|
||||
### `balancedPool.stream(options, factory[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
-->
|
||||
|
||||
See [`Dispatcher.stream(options, factory[, callback])`][].
|
||||
|
||||
### `balancedPool.upgrade(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
-->
|
||||
|
||||
See [`Dispatcher.upgrade(options[, callback])`][].
|
||||
|
||||
### Event: `'connect'`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
changes:
|
||||
- version: v4.10.4
|
||||
pr-url: https://github.com/nodejs/undici/pull/1114
|
||||
description: Introduced the `BalancedPool` class.
|
||||
- version: v5.8.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/1069
|
||||
description: Adopted weighted round-robin selection across upstreams.
|
||||
-->
|
||||
|
||||
See [Dispatcher Event: `'connect'`][].
|
||||
|
||||
### Event: `'disconnect'`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
changes:
|
||||
- version: v4.10.4
|
||||
pr-url: https://github.com/nodejs/undici/pull/1114
|
||||
description: Introduced the `BalancedPool` class.
|
||||
- version: v5.8.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/1069
|
||||
description: Adopted weighted round-robin selection across upstreams.
|
||||
-->
|
||||
|
||||
See [Dispatcher Event: `'disconnect'`][].
|
||||
|
||||
### Event: `'drain'`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.0
|
||||
changes:
|
||||
- version: v4.10.4
|
||||
pr-url: https://github.com/nodejs/undici/pull/1114
|
||||
description: Introduced the `BalancedPool` class.
|
||||
-->
|
||||
|
||||
See [Dispatcher Event: `'drain'`][].
|
||||
|
||||
[Dispatcher Event: `'connect'`]: Dispatcher.md#event-connect
|
||||
[Dispatcher Event: `'disconnect'`]: Dispatcher.md#event-disconnect
|
||||
[Dispatcher Event: `'drain'`]: Dispatcher.md#event-drain
|
||||
[`Dispatcher.close([callback])`]: Dispatcher.md#dispatcherclosecallback-promise
|
||||
[`Dispatcher.connect(options[, callback])`]: Dispatcher.md#dispatcherconnectoptions-callback
|
||||
[`Dispatcher.destroy([error, callback])`]: Dispatcher.md#dispatcherdestroyerror-callback-promise
|
||||
[`Dispatcher.dispatch(options, handler)`]: Dispatcher.md#dispatcherdispatchoptions-handler
|
||||
[`Dispatcher.pipeline(options, handler)`]: Dispatcher.md#dispatcherpipelineoptions-handler
|
||||
[`Dispatcher.request(options[, callback])`]: Dispatcher.md#dispatcherrequestoptions-callback
|
||||
[`Dispatcher.stream(options, factory[, callback])`]: Dispatcher.md#dispatcherstreamoptions-factory-callback
|
||||
[`Dispatcher.upgrade(options[, callback])`]: Dispatcher.md#dispatcherupgradeoptions-callback
|
||||
[`client.closed`]: Client.md#clientclosed
|
||||
[`client.destroyed`]: Client.md#clientdestroyed
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
# CacheStorage
|
||||
|
||||
<!--introduced_in=v5.22.1-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/web/cache/cachestorage.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
undici provides a spec-compliant implementation of the [W3C Service Worker
|
||||
`CacheStorage`][] and [`Cache`][] interfaces. A `CacheStorage` is a named
|
||||
collection of [`Cache`][] objects, each of which stores `Request`/`Response`
|
||||
pairs.
|
||||
|
||||
The class itself is not exported; instead, undici exposes a single shared
|
||||
`CacheStorage` instance named `caches`. Import it from `'undici'`:
|
||||
|
||||
```mjs
|
||||
import { caches } from 'undici'
|
||||
|
||||
const cache = await caches.open('v1')
|
||||
```
|
||||
|
||||
```cjs
|
||||
const { caches } = require('undici')
|
||||
|
||||
caches.open('v1').then((cache) => {
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
## `caches`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.22.1
|
||||
-->
|
||||
|
||||
* Type: {CacheStorage}
|
||||
|
||||
The shared [`CacheStorage`](#class-cachestorage) instance exported by undici.
|
||||
All caches opened through `caches` live for the lifetime of the process.
|
||||
|
||||
```mjs
|
||||
import { caches } from 'undici'
|
||||
|
||||
await caches.open('v1')
|
||||
console.log(await caches.keys()) // ['v1']
|
||||
```
|
||||
|
||||
## Class: `CacheStorage`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.22.1
|
||||
-->
|
||||
|
||||
Represents the storage for [`Cache`][] objects. Each `CacheStorage` keeps a
|
||||
mapping of cache names to their backing list of cached responses.
|
||||
|
||||
The constructor is not public: attempting to instantiate `CacheStorage`
|
||||
directly throws a `TypeError`. Use the exported [`caches`](#caches) instance
|
||||
instead.
|
||||
|
||||
### `caches.match(request[, options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.22.1
|
||||
-->
|
||||
|
||||
* `request` {Request|string} The request to match against. A string is treated
|
||||
as a URL.
|
||||
* `options` {Object} (optional)
|
||||
* `ignoreSearch` {boolean} When `true`, ignores the query string of the URL
|
||||
when matching. **Default:** `false`.
|
||||
* `ignoreMethod` {boolean} When `true`, prevents matching operations from
|
||||
validating the `Request` `http` method. **Default:** `false`.
|
||||
* `ignoreVary` {boolean} When `true`, ignores the `Vary` header when matching.
|
||||
**Default:** `false`.
|
||||
* `cacheName` {string} When set, restricts the search to the cache with this
|
||||
name. **Default:** `undefined`.
|
||||
* Returns: {Promise} Fulfills with the first matching {Response}, or
|
||||
`undefined` if no response matched.
|
||||
|
||||
Searches the caches for a response matching `request`. When `options.cacheName`
|
||||
is provided, only that cache is searched; otherwise every cache is searched in
|
||||
insertion order and the first match is returned.
|
||||
|
||||
```mjs
|
||||
import { caches } from 'undici'
|
||||
|
||||
const response = await caches.match('https://example.com/data', {
|
||||
cacheName: 'v1'
|
||||
})
|
||||
```
|
||||
|
||||
### `caches.has(cacheName)`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.22.1
|
||||
-->
|
||||
|
||||
* `cacheName` {string} The name of the cache to look up.
|
||||
* Returns: {Promise} Fulfills with `true` if a cache named `cacheName` exists,
|
||||
otherwise `false`.
|
||||
|
||||
Checks whether a cache with the given name exists in the storage.
|
||||
|
||||
```mjs
|
||||
import { caches } from 'undici'
|
||||
|
||||
await caches.open('v1')
|
||||
console.log(await caches.has('v1')) // true
|
||||
console.log(await caches.has('v2')) // false
|
||||
```
|
||||
|
||||
### `caches.open(cacheName)`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.22.1
|
||||
-->
|
||||
|
||||
* `cacheName` {string} The name of the cache to open.
|
||||
* Returns: {Promise} Fulfills with the {Cache} named `cacheName`.
|
||||
|
||||
Opens the cache named `cacheName`, creating it if it does not already exist.
|
||||
|
||||
Each call returns a new [`Cache`][] object, but caches opened with the same name
|
||||
share their underlying list of cached responses. As a result, a response stored
|
||||
through one instance is visible through another instance with the same name.
|
||||
|
||||
```mjs
|
||||
import { caches } from 'undici'
|
||||
import assert from 'node:assert'
|
||||
|
||||
const cache1 = await caches.open('v1')
|
||||
const cache2 = await caches.open('v1')
|
||||
|
||||
// open() returns distinct objects,
|
||||
assert(cache1 !== cache2)
|
||||
// but they share the same cached responses.
|
||||
assert.deepStrictEqual(await cache1.match('/req'), await cache2.match('/req'))
|
||||
```
|
||||
|
||||
### `caches.delete(cacheName)`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.22.1
|
||||
-->
|
||||
|
||||
* `cacheName` {string} The name of the cache to delete.
|
||||
* Returns: {Promise} Fulfills with `true` if the cache existed and was deleted,
|
||||
otherwise `false`.
|
||||
|
||||
Removes the cache named `cacheName` from the storage. Any [`Cache`][] objects
|
||||
already obtained for that name, along with the `Request`/`Response` pairs they
|
||||
returned, remain usable after deletion.
|
||||
|
||||
```mjs
|
||||
import { caches } from 'undici'
|
||||
|
||||
const cache = await caches.open('v1')
|
||||
const response = await cache.match('/req')
|
||||
|
||||
await caches.delete('v1')
|
||||
|
||||
// The already-retrieved response is still usable.
|
||||
await response.text()
|
||||
```
|
||||
|
||||
### `caches.keys()`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.22.1
|
||||
-->
|
||||
|
||||
* Returns: {Promise} Fulfills with a {string[]} of cache names in insertion
|
||||
order.
|
||||
|
||||
Returns the names of all caches currently held by the storage.
|
||||
|
||||
```mjs
|
||||
import { caches } from 'undici'
|
||||
|
||||
await caches.open('v1')
|
||||
await caches.open('v2')
|
||||
console.log(await caches.keys()) // ['v1', 'v2']
|
||||
```
|
||||
|
||||
[W3C Service Worker `CacheStorage`]: https://w3c.github.io/ServiceWorker/#cachestorage-interface
|
||||
[`Cache`]: https://w3c.github.io/ServiceWorker/#cache-interface
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
# Cache Store
|
||||
|
||||
<!--introduced_in=v7.0.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/cache/memory-cache-store.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A cache store is the storage backend used by the cache interceptor to persist
|
||||
and retrieve cached responses. A store decides which stored response to serve
|
||||
for a request by comparing the request against the response's `Vary` header, and
|
||||
is expected to be compliant with [RFC 9111][].
|
||||
|
||||
undici ships two stores. `MemoryCacheStore` keeps responses in memory, while
|
||||
`SqliteCacheStore` persists them to a SQLite database. Both are available under
|
||||
the `cacheStores` export:
|
||||
|
||||
```mjs
|
||||
import { cacheStores } from 'undici'
|
||||
|
||||
const { MemoryCacheStore, SqliteCacheStore } = cacheStores
|
||||
```
|
||||
|
||||
`SqliteCacheStore` requires the [`node:sqlite`][] API. The class is always
|
||||
exported, but constructing one throws if `node:sqlite` is not available in the
|
||||
running Node.js version.
|
||||
|
||||
A cache store implements three methods — `get(key)`, `createWriteStream(key,
|
||||
value)`, and `delete(key)` — that operate on a [`CacheKey`](#cachekey) and a
|
||||
[`CacheValue`](#cachevalue). Any object implementing this contract can be passed
|
||||
to the cache interceptor, so custom stores (for example, backed by Redis or a
|
||||
remote service) are supported as well.
|
||||
|
||||
## Class: `MemoryCacheStore`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* Extends: {EventEmitter}
|
||||
|
||||
Stores cached responses in memory. The store enforces upper bounds on the total
|
||||
number of responses, the total size of all responses, and the size of any single
|
||||
response. When a limit is exceeded, the store evicts approximately half of its
|
||||
entries and emits a [`'maxSizeExceeded'`](#event-maxsizeexceeded) event.
|
||||
|
||||
```mjs
|
||||
import { interceptors, cacheStores, Agent, setGlobalDispatcher } from 'undici'
|
||||
|
||||
const store = new cacheStores.MemoryCacheStore({ maxSize: 50 * 1024 * 1024 })
|
||||
|
||||
setGlobalDispatcher(
|
||||
new Agent().compose(interceptors.cache({ store }))
|
||||
)
|
||||
```
|
||||
|
||||
### `new MemoryCacheStore([options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `options` {Object} (optional)
|
||||
* `maxCount` {number} The maximum number of responses to store. **Default:**
|
||||
`1024`.
|
||||
* `maxSize` {number} The maximum total size, in bytes, of all stored
|
||||
responses. **Default:** `104857600` (100 MiB).
|
||||
* `maxEntrySize` {number} The maximum size, in bytes, of a single response
|
||||
body. Responses whose body exceeds this value are not cached. **Default:**
|
||||
`5242880` (5 MiB).
|
||||
* `errorCallback` {Function} A callback invoked with any error raised by the
|
||||
store. (optional)
|
||||
* `err` {Error}
|
||||
|
||||
Each of `maxCount`, `maxSize`, and `maxEntrySize` must be a non-negative
|
||||
integer; a `TypeError` is thrown otherwise.
|
||||
|
||||
### `memoryCacheStore.size`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* Type: {number}
|
||||
|
||||
The current total size, in bytes, of all stored response bodies.
|
||||
|
||||
### `memoryCacheStore.isFull()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* Returns: {boolean} `true` when the store has reached its `maxSize` or
|
||||
`maxCount` limit, `false` otherwise.
|
||||
|
||||
### `memoryCacheStore.get(key)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `key` {CacheKey} The request to look up. See [`CacheKey`](#cachekey).
|
||||
* Returns: {GetResult|undefined} The matching cached response, or `undefined`
|
||||
when there is no fresh entry for `key`. See [`GetResult`](#getresult).
|
||||
|
||||
Looks up a cached response for `key`. A stored entry matches only when its
|
||||
`method` equals `key.method`, its `deleteAt` time is in the future, and every
|
||||
header listed in its `vary` map matches the corresponding header in
|
||||
`key.headers`.
|
||||
|
||||
### `memoryCacheStore.createWriteStream(key, value)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `key` {CacheKey} The request the response is being cached for. See
|
||||
[`CacheKey`](#cachekey).
|
||||
* `value` {CacheValue} The response metadata to store. See
|
||||
[`CacheValue`](#cachevalue).
|
||||
* Returns: {Writable|undefined} A writable stream that the response body is
|
||||
written to, or `undefined` when the response cannot be cached.
|
||||
|
||||
Returns a {Writable} stream used to write the response body into the store. When
|
||||
the stream finishes, the entry is committed; if its accumulated size exceeds
|
||||
`maxEntrySize`, the stream is destroyed and nothing is stored. Writing a new
|
||||
entry whose `key` matches an existing one replaces that entry.
|
||||
|
||||
### `memoryCacheStore.delete(key)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `key` {CacheKey} The request whose cached responses should be removed. See
|
||||
[`CacheKey`](#cachekey).
|
||||
* Returns: {undefined}
|
||||
|
||||
Removes every cached response stored for `key.origin` and `key.path`. Throws a
|
||||
`TypeError` if `key` is not an object.
|
||||
|
||||
### Event: `'maxSizeExceeded'`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.10.0
|
||||
-->
|
||||
|
||||
* `data` {Object}
|
||||
* `size` {number} The current total size, in bytes, of all stored responses.
|
||||
* `maxSize` {number} The configured `maxSize` limit.
|
||||
* `count` {number} The current number of stored responses.
|
||||
* `maxCount` {number} The configured `maxCount` limit.
|
||||
|
||||
Emitted when the store exceeds its `maxSize` or `maxCount` limit, immediately
|
||||
before entries are evicted. The event fires only once per overflow; it is not
|
||||
emitted again until the store drops back below both limits.
|
||||
|
||||
## Class: `SqliteCacheStore`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
Stores cached responses in a SQLite database using the [`node:sqlite`][] API.
|
||||
The constructor throws when [`node:sqlite`][] is not available.
|
||||
|
||||
```mjs
|
||||
import { interceptors, cacheStores, Agent, setGlobalDispatcher } from 'undici'
|
||||
|
||||
const store = new cacheStores.SqliteCacheStore({ location: './cache.db' })
|
||||
|
||||
setGlobalDispatcher(
|
||||
new Agent().compose(interceptors.cache({ store }))
|
||||
)
|
||||
```
|
||||
|
||||
### `new SqliteCacheStore([options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `options` {Object} (optional)
|
||||
* `location` {string} The location of the SQLite database. Use `':memory:'`
|
||||
for an in-memory database. **Default:** `':memory:'`.
|
||||
* `maxCount` {number} The maximum number of responses to store. **Default:**
|
||||
`Infinity`.
|
||||
* `maxEntrySize` {number} The maximum size, in bytes, of a single response
|
||||
body. Responses whose body exceeds this value are not cached. Must not
|
||||
exceed `2000000000` (2 GB). **Default:** `2000000000` (2 GB).
|
||||
|
||||
`maxCount` and `maxEntrySize` must be non-negative integers; a `TypeError` is
|
||||
thrown otherwise, or if `maxEntrySize` is greater than 2 GB.
|
||||
|
||||
### `sqliteCacheStore.close()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* Returns: {undefined}
|
||||
|
||||
Closes the connection to the underlying SQLite database.
|
||||
|
||||
### `sqliteCacheStore.size`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* Type: {number}
|
||||
|
||||
The number of responses currently stored in the database.
|
||||
|
||||
### `sqliteCacheStore.get(key)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `key` {CacheKey} The request to look up. See [`CacheKey`](#cachekey).
|
||||
* Returns: {GetResult|undefined} The matching cached response, or `undefined`
|
||||
when there is no fresh entry for `key`. The returned `body` is a {Buffer}.
|
||||
See [`GetResult`](#getresult).
|
||||
|
||||
Looks up a cached response for `key`, comparing the request method and every
|
||||
header named in the stored `vary` map.
|
||||
|
||||
### `sqliteCacheStore.set(key, value)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `key` {CacheKey} The request the response is being cached for. See
|
||||
[`CacheKey`](#cachekey).
|
||||
* `value` {CacheValue} The response to store, with its `body` set to a {Buffer},
|
||||
an {Array} of {Buffer}, or `null`. See [`CacheValue`](#cachevalue).
|
||||
* Returns: {undefined}
|
||||
|
||||
Writes a response into the database directly, without going through a stream. If
|
||||
the body exceeds `maxEntrySize`, nothing is stored. When an entry already exists
|
||||
for `key` it is overwritten; otherwise a new row is inserted and old entries are
|
||||
pruned to honour `maxCount`. This method is used internally by
|
||||
[`sqliteCacheStore.createWriteStream()`](#sqlitecachestorecreatewritestreamkey-value).
|
||||
|
||||
### `sqliteCacheStore.createWriteStream(key, value)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `key` {CacheKey} The request the response is being cached for. See
|
||||
[`CacheKey`](#cachekey).
|
||||
* `value` {CacheValue} The response metadata to store. See
|
||||
[`CacheValue`](#cachevalue).
|
||||
* Returns: {Writable|undefined} A writable stream that the response body is
|
||||
written to, or `undefined` when the response cannot be cached.
|
||||
|
||||
Returns a {Writable} stream used to write the response body into the store. When
|
||||
the stream finishes, the buffered body is committed via
|
||||
[`sqliteCacheStore.set()`](#sqlitecachestoresetkey-value). If the body exceeds
|
||||
`maxEntrySize`, the stream is destroyed and nothing is stored.
|
||||
|
||||
### `sqliteCacheStore.delete(key)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `key` {CacheKey} The request whose cached responses should be removed. See
|
||||
[`CacheKey`](#cachekey).
|
||||
* Returns: {undefined}
|
||||
|
||||
Removes every cached response stored for the URL derived from `key.origin` and
|
||||
`key.path`. Throws a `TypeError` if `key` is not an object.
|
||||
|
||||
## Implementing a custom cache store
|
||||
|
||||
A cache store is any object that implements the `CacheStore` interface:
|
||||
|
||||
* `get(key)` — Look up a response. Returns a [`GetResult`](#getresult),
|
||||
`undefined`, or a {Promise} resolving to either.
|
||||
* `createWriteStream(key, value)` — Return a {Writable} to receive the response
|
||||
body, or `undefined` if the response cannot be stored.
|
||||
* `delete(key)` — Remove all responses for `key`. May return a {Promise}.
|
||||
|
||||
Returning a {Promise} from `get` or `delete` lets a store be backed by an
|
||||
asynchronous resource; the cache interceptor awaits these values, including on
|
||||
the revalidation and stale-while-revalidate paths.
|
||||
|
||||
### `CacheKey`
|
||||
|
||||
The request a response is being looked up or stored for.
|
||||
|
||||
* `origin` {string} The request origin.
|
||||
* `method` {string} The request method.
|
||||
* `path` {string} The request path.
|
||||
* `headers` {Record<string, string|string[]>} The request headers, used to
|
||||
satisfy `Vary`. (optional)
|
||||
|
||||
### `CacheValue`
|
||||
|
||||
The metadata of a cached response, excluding its body.
|
||||
|
||||
* `statusCode` {number} The response's HTTP status code.
|
||||
* `statusMessage` {string} The response's HTTP status message.
|
||||
* `headers` {Record<string, string|string[]>} The response headers.
|
||||
* `vary` {Record<string, string|string[]|null>} The header names listed in the
|
||||
response's `Vary` header mapped to the values they had in the original
|
||||
request. A value is `null` when that header was absent from the original
|
||||
request. (optional)
|
||||
* `etag` {string} The response's entity tag. (optional)
|
||||
* `cacheControlDirectives` {Object} The parsed `Cache-Control` directives of the
|
||||
response. (optional)
|
||||
* `cachedAt` {number} The time, in milliseconds, at which the response was
|
||||
cached.
|
||||
* `staleAt` {number} The time, in milliseconds, at which the response becomes
|
||||
stale.
|
||||
* `deleteAt` {number} The time, in milliseconds, at which the response must be
|
||||
evicted. A store must not return a response once this time has passed.
|
||||
|
||||
The `vary` map drives response selection. For a response such as:
|
||||
|
||||
```http
|
||||
Vary: content-encoding, accept
|
||||
content-encoding: utf8
|
||||
accept: application/json
|
||||
```
|
||||
|
||||
the recorded map is:
|
||||
|
||||
```js
|
||||
{
|
||||
'content-encoding': 'utf8',
|
||||
accept: 'application/json'
|
||||
}
|
||||
```
|
||||
|
||||
If the original request did not include the `accept` header, its value is
|
||||
recorded as `null`:
|
||||
|
||||
```js
|
||||
{
|
||||
'content-encoding': 'utf8',
|
||||
accept: null
|
||||
}
|
||||
```
|
||||
|
||||
### `GetResult`
|
||||
|
||||
The value returned by `get`. It contains every field of
|
||||
[`CacheValue`](#cachevalue) plus the response body.
|
||||
|
||||
* `body` {Readable|Iterable|AsyncIterable|Buffer|string} The cached response
|
||||
body. (optional)
|
||||
|
||||
[RFC 9111]: https://www.rfc-editor.org/rfc/rfc9111.html
|
||||
[`node:sqlite`]: https://nodejs.org/api/sqlite.html
|
||||
+533
@@ -0,0 +1,533 @@
|
||||
# Client
|
||||
|
||||
<!--introduced_in=v1.0.0-->
|
||||
|
||||
<!--type=module-->
|
||||
|
||||
<!-- source_link=lib/dispatcher/client.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A basic HTTP/1.1 client mapped on top of a single TCP/TLS connection. When the
|
||||
server supports HTTP/2 and selects it during ALPN negotiation, the same
|
||||
connection is used for HTTP/2. Pipelining is disabled by default.
|
||||
|
||||
`Client` extends [`Dispatcher`][] and is the lowest-level dispatcher in undici:
|
||||
it manages exactly one origin over one connection. For pooling across multiple
|
||||
connections use [`Pool`][], and for routing across multiple origins use
|
||||
[`Agent`][]. Requests are not guaranteed to be dispatched in the order in which
|
||||
they are invoked.
|
||||
|
||||
```mjs
|
||||
import { Client } from 'undici'
|
||||
|
||||
const client = new Client('http://localhost:3000')
|
||||
const { statusCode, body } = await client.request({ path: '/', method: 'GET' })
|
||||
```
|
||||
|
||||
## Class: `Client`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Extends: {Dispatcher}
|
||||
|
||||
### `new Client(url[, options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `url` {URL|string} The origin to connect to. Only the protocol, hostname, and
|
||||
port are used.
|
||||
* `options` {Object} (optional)
|
||||
* `bodyTimeout` {number|null} The timeout, in milliseconds, after which a
|
||||
request times out while receiving body data. Monitors the time between
|
||||
consecutive body chunks. Use `0` to disable it entirely. **Default:**
|
||||
`300e3`.
|
||||
* `headersTimeout` {number|null} The timeout, in milliseconds, the parser
|
||||
waits to receive the complete HTTP headers before the request times out. Use
|
||||
`0` to disable it entirely. **Default:** `300e3`.
|
||||
HTTP/1.1 headers/body parser timeouts are not guaranteed to fire with exact
|
||||
millisecond precision: delays up to 1000ms use native timers, while larger
|
||||
delays use undici's lower-overhead fast timers with a target resolution
|
||||
around 500ms.
|
||||
* `connectTimeout` {number|null} The timeout, in milliseconds, for
|
||||
establishing a socket connection. Use `0` to disable it entirely.
|
||||
**Default:** `10e3`.
|
||||
* `keepAliveTimeout` {number|null} The timeout, in milliseconds, after which a
|
||||
socket with no active requests times out. Monitors the time between activity
|
||||
on a connected socket. This value may be overridden by *keep-alive* hints
|
||||
from the server. **Default:** `4e3`.
|
||||
* `keepAliveMaxTimeout` {number|null} The maximum allowed `keepAliveTimeout`,
|
||||
in milliseconds, when overridden by *keep-alive* hints from the server.
|
||||
**Default:** `600e3`.
|
||||
* `keepAliveTimeoutThreshold` {number|null} A number of milliseconds
|
||||
subtracted from server *keep-alive* hints when overriding `keepAliveTimeout`,
|
||||
to account for timing inaccuracies caused by e.g. transport latency.
|
||||
**Default:** `2e3`.
|
||||
* `maxHeaderSize` {number|null} The maximum length of request headers, in
|
||||
bytes. **Default:** the value of Node.js' `--max-http-header-size` or
|
||||
`16384` (16 KiB).
|
||||
* `maxResponseSize` {number|null} The maximum length of a response body, in
|
||||
bytes. Set to `-1` to disable it. **Default:** `-1`.
|
||||
* `maxRequestsPerClient` {number|null} The maximum number of requests to send
|
||||
over a single connection before the socket is reset. Use `0` to disable this
|
||||
limit. **Default:** `null`.
|
||||
* `localAddress` {string|null} The local IP address the socket should connect
|
||||
from. **Default:** `null`.
|
||||
* `pipelining` {number|null} The number of concurrent requests sent over the
|
||||
single connection, per [RFC 7230, section 6.3.2][]. Only enable values
|
||||
greater than `1` when the remote server is trusted. Set to `0` to disable
|
||||
keep-alive connections. Has no effect once HTTP/2 is negotiated; see
|
||||
`maxConcurrentStreams` for the HTTP/2 dispatch ceiling. **Default:** `1`.
|
||||
* `connect` {Object|Function|null} Configures how connections are established.
|
||||
Either a `ConnectOptions` object passed to the built-in
|
||||
[`buildConnector`][] (every [`tls.connect()`][] option is accepted, plus the
|
||||
fields below), or a custom connector function with the signature
|
||||
`(options, callback)`. **Default:** `null`.
|
||||
* `socketPath` {string|null} An IPC endpoint, either a Unix domain socket or
|
||||
a Windows named pipe. **Default:** `null`.
|
||||
* `maxCachedSessions` {number|null} The maximum number of cached TLS
|
||||
sessions. Use `0` to disable TLS session caching. **Default:** `100`.
|
||||
* `timeout` {number|null} The connection timeout, in milliseconds.
|
||||
**Default:** `10e3`.
|
||||
* `servername` {string|null} The TLS server name used for SNI.
|
||||
* `keepAlive` {boolean|null} Whether TCP keep-alive is enabled on the socket.
|
||||
**Default:** `true`.
|
||||
* `keepAliveInitialDelay` {number|null} The TCP keep-alive interval for the
|
||||
socket, in milliseconds. **Default:** `60000`.
|
||||
* `strictContentLength` {boolean} Whether to throw when the request
|
||||
`content-length` header does not match the length of the request body.
|
||||
Disabling this exposes the application to HTTP request smuggling; only do so
|
||||
in controlled environments where the request source is fully trusted.
|
||||
**Default:** `true`.
|
||||
* `autoSelectFamily` {boolean} Enables a family autodetection algorithm that
|
||||
loosely implements section 5 of [RFC 8305][]. Ignored if unsupported by the
|
||||
current Node.js version. **Default:** `false`.
|
||||
* `autoSelectFamilyAttemptTimeout` {number} The time, in milliseconds, to wait
|
||||
for a connection attempt to finish before trying the next address when
|
||||
`autoSelectFamily` is enabled. **Default:** `250`.
|
||||
* `allowH2` {boolean} Enables HTTP/2 support when the server assigns it a
|
||||
higher priority through ALPN negotiation. **Default:** `true`.
|
||||
* `useH2c` {boolean} _Deprecated: use h2Options.useH2c instead_ Enforces h2c (HTTP/2 cleartext) for non-HTTPS
|
||||
connections. **Default:** `false`.
|
||||
* `maxConcurrentStreams` {number} _Deprecated: use h2Options.useH2c instead_ The maximum number of concurrent HTTP/2
|
||||
streams for a single session. Once h2 is negotiated this — not `pipelining`,
|
||||
which is HTTP/1.1 only — is the ceiling used to dispatch in-flight requests.
|
||||
It may be overridden by the server's `SETTINGS_MAX_CONCURRENT_STREAMS`
|
||||
frame. **Default:** `100`.
|
||||
* `connectionWindowSize` {number} _Deprecated: use h2Options.connectionWindowSize instead_ The HTTP/2 connection-level flow-control
|
||||
window size set via `ClientHttp2Session.setLocalWindowSize()`. Must be a
|
||||
positive integer. **Default:** `524288`.
|
||||
* `pingInterval` {number} _Deprecated: use h2Options.pingInterval instead_ The time interval, in milliseconds, between HTTP/2
|
||||
PING frames. Set to `0` to disable PING frames. Applies only to HTTP/2
|
||||
connections and emits a `ping` event on the client. **Default:** `60e3`.
|
||||
* `h2Options` {object} Set of options for HTTP/2 sessions
|
||||
* `useH2c` {boolean} Enforces h2c (HTTP/2 cleartext) for non-HTTPS
|
||||
connections. **Default:** `false`.
|
||||
* `maxConcurrentStreams` {number} The maximum number of concurrent HTTP/2
|
||||
streams for a single session. Once h2 is negotiated this — not `pipelining`,
|
||||
which is HTTP/1.1 only — is the ceiling used to dispatch in-flight requests.
|
||||
It may be overridden by the server's `SETTINGS_MAX_CONCURRENT_STREAMS`
|
||||
frame. **Default:** `100`.
|
||||
* `connectionWindowSize` {number} The HTTP/2 connection-level flow-control
|
||||
window size set via `ClientHttp2Session.setLocalWindowSize()`. Must be a
|
||||
positive integer. **Default:** `524288`.
|
||||
* `pingInterval` {number} The time interval, in milliseconds, between HTTP/2
|
||||
PING frames. Set to `0` to disable PING frames. Applies only to HTTP/2
|
||||
connections and emits a `ping` event on the client. **Default:** `60e3`.
|
||||
* `settings` {object} `SETTINGS` frame options. For full reference, take a
|
||||
look to [HTTP/2#Settings Object](https://nodejs.org/api/http2.html#settings-object)
|
||||
* `webSocket` {Object} (optional) WebSocket-specific configuration.
|
||||
* `maxFragments` {number} The maximum number of fragments in a message. Set
|
||||
to `0` to disable the limit. **Default:** `131072`.
|
||||
* `maxPayloadSize` {number} The maximum allowed payload size, in bytes, for
|
||||
WebSocket messages. Applied to uncompressed messages, compressed frame
|
||||
payloads, and decompressed (`permessage-deflate`) messages. Set to `0` to
|
||||
disable the limit. **Default:** `134217728`.
|
||||
* `eventSource` {Object} (optional) EventSource-specific configuration.
|
||||
* `maxEventSize` {number} The maximum allowed event size, in bytes, for
|
||||
EventSource messages. Set to `0` to disable the limit.
|
||||
**Default:** `buffer.kStringMaxLength`.
|
||||
* Returns: {Client}
|
||||
|
||||
Instantiating a `Client` does not open a connection; the connection is
|
||||
established lazily once a request is queued. Call `client.connect()` to connect
|
||||
eagerly.
|
||||
|
||||
> Notes about HTTP/2:
|
||||
>
|
||||
> * Pseudo headers (`:path`, `:method`, `:scheme`, `:authority`) are attached
|
||||
> automatically and overwrite any user-provided values.
|
||||
> * The server must support HTTP/2 and select it during ALPN negotiation, and
|
||||
> must not give HTTP/1.1 a higher priority than HTTP/2.
|
||||
> * `PUSH` frames are not supported.
|
||||
|
||||
```mjs displayName="Basic instantiation"
|
||||
import { Client } from 'undici'
|
||||
|
||||
const client = new Client('http://localhost:3000')
|
||||
```
|
||||
|
||||
```mjs displayName="TLS options (object connector)"
|
||||
import { Client } from 'undici'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const client = new Client('https://localhost:3000', {
|
||||
connect: {
|
||||
rejectUnauthorized: false,
|
||||
ca: readFileSync('./ca-cert.pem')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```mjs displayName="Unix domain socket"
|
||||
import { Client } from 'undici'
|
||||
|
||||
const client = new Client('http://localhost:3000', {
|
||||
connect: {
|
||||
socketPath: '/var/run/docker.sock'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```mjs displayName="Custom DNS lookup"
|
||||
import { Client } from 'undici'
|
||||
import dns from 'node:dns'
|
||||
|
||||
const client = new Client('https://example.com', {
|
||||
connect: {
|
||||
lookup (hostname, options, callback) {
|
||||
dns.lookup(hostname, { ...options, family: 4 }, callback)
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```mjs displayName="Custom connector (function)"
|
||||
import { Client, buildConnector } from 'undici'
|
||||
|
||||
const connector = buildConnector({ rejectUnauthorized: false })
|
||||
const client = new Client('https://localhost:3000', {
|
||||
connect (opts, cb) {
|
||||
connector(opts, (err, socket) => {
|
||||
if (err) {
|
||||
cb(err)
|
||||
} else {
|
||||
cb(null, socket)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
When a connector function is provided, undici wraps it to automatically inject
|
||||
`socketPath` and `allowH2` into the `options` argument when those values are set
|
||||
on the client. See [`Connector`][] for details on building custom connectors.
|
||||
|
||||
### `client.close([callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
Implements [`dispatcher.close([callback])`][]. Closes the client gracefully,
|
||||
waiting for enqueued requests to complete before resolving.
|
||||
|
||||
### `client.destroy([error[, callback]])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
Implements [`dispatcher.destroy([error[, callback]])`][]. Destroys the client
|
||||
abruptly, aborting all pending and running requests, and waits until the socket
|
||||
is closed before invoking `callback` (or resolving the returned promise).
|
||||
|
||||
### `client.connect(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `options` {Object} The connect options, identical to
|
||||
[`dispatcher.connect()`][] except that `origin` is omitted because the client
|
||||
is bound to a single origin.
|
||||
* `callback` {Function} (optional)
|
||||
* Returns: {Promise|void} A promise when `callback` is not provided.
|
||||
|
||||
Starts a connection to the client's origin and tunnels a connection through it,
|
||||
as defined by [`dispatcher.connect()`][].
|
||||
|
||||
### `client.dispatch(options, handlers)`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
Implements [`dispatcher.dispatch(options, handler)`][]. This is the lowest-level
|
||||
API used by all other request methods; prefer `client.request()` for most use
|
||||
cases.
|
||||
|
||||
### `client.pipeline(options, handler)`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
See [`dispatcher.pipeline(options, handler)`][]. Sends a request and returns a
|
||||
{Duplex} stream that the request body is written to and the response body is
|
||||
read from.
|
||||
|
||||
### `client.request(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `options` {Object}
|
||||
* `callback` {Function} (optional)
|
||||
* Returns: {Promise|void} A promise resolving to the response data when
|
||||
`callback` is not provided.
|
||||
|
||||
See [`dispatcher.request(options[, callback])`][]. Performs an HTTP request and
|
||||
returns its status code, headers, trailers, and a readable body stream.
|
||||
|
||||
```mjs
|
||||
import { Client } from 'undici'
|
||||
|
||||
const client = new Client('http://localhost:3000')
|
||||
const { statusCode, headers, body } = await client.request({
|
||||
path: '/',
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
console.log(statusCode)
|
||||
for await (const chunk of body) {
|
||||
console.log(chunk.toString())
|
||||
}
|
||||
```
|
||||
|
||||
### `client.stream(options, factory[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `options` {Object}
|
||||
* `factory` {Function} Returns a {Writable} the response body is piped into.
|
||||
* `callback` {Function} (optional)
|
||||
* Returns: {Promise|void} A promise when `callback` is not provided.
|
||||
|
||||
See [`dispatcher.stream(options, factory[, callback])`][]. A faster alternative
|
||||
to `client.request()` when the destination of the response body is known in
|
||||
advance.
|
||||
|
||||
### `client.upgrade(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `options` {Object}
|
||||
* `callback` {Function} (optional)
|
||||
* Returns: {Promise|void} A promise when `callback` is not provided.
|
||||
|
||||
See [`dispatcher.upgrade(options[, callback])`][]. Upgrades a connection to a
|
||||
different protocol, such as for WebSocket or HTTP `CONNECT`.
|
||||
|
||||
### `client.closed`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {boolean}
|
||||
|
||||
`true` after `client.close()` has been called.
|
||||
|
||||
### `client.destroyed`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {boolean}
|
||||
|
||||
`true` after `client.destroy()` has been called, or after `client.close()` has
|
||||
been called and the client shutdown has completed.
|
||||
|
||||
### `client.pipelining`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {number}
|
||||
|
||||
The pipelining factor. This property can be read and written to adjust the
|
||||
number of concurrent requests sent over the connection. Only enable pipelining
|
||||
with trusted remote servers.
|
||||
|
||||
### `client.stats`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.9.0
|
||||
-->
|
||||
|
||||
* Type: {ClientStats}
|
||||
|
||||
Aggregate statistics for the client. See [`ClientStats`][].
|
||||
|
||||
### Event: `'connect'`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `origin` {URL}
|
||||
* `targets` {Array<Dispatcher>}
|
||||
|
||||
Emitted when a socket has been created and connected. The client connects once
|
||||
`client.size > 0`. See [`Dispatcher` Event: `'connect'`][].
|
||||
|
||||
```mjs
|
||||
import { createServer } from 'node:http'
|
||||
import { Client } from 'undici'
|
||||
import { once } from 'node:events'
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
response.end('Hello, World!')
|
||||
}).listen()
|
||||
|
||||
await once(server, 'listening')
|
||||
|
||||
const client = new Client(`http://localhost:${server.address().port}`)
|
||||
|
||||
client.on('connect', (origin) => {
|
||||
console.log(`Connected to ${origin}`)
|
||||
})
|
||||
|
||||
const { body } = await client.request({ path: '/', method: 'GET' })
|
||||
body.setEncoding('utf8')
|
||||
body.on('data', console.log)
|
||||
|
||||
client.close()
|
||||
server.close()
|
||||
```
|
||||
|
||||
### Event: `'disconnect'`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v4.0.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/771
|
||||
description: The event is only emitted if the client had previously
|
||||
connected.
|
||||
-->
|
||||
|
||||
* `origin` {URL}
|
||||
* `targets` {Array<Dispatcher>}
|
||||
* `error` {Error}
|
||||
|
||||
Emitted when a socket has disconnected. The `error` argument is the error that
|
||||
caused the disconnection. The client reconnects if or once `client.size > 0`.
|
||||
See [`Dispatcher` Event: `'disconnect'`][].
|
||||
|
||||
```mjs
|
||||
import { createServer } from 'node:http'
|
||||
import { Client } from 'undici'
|
||||
import { once } from 'node:events'
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
response.destroy()
|
||||
}).listen()
|
||||
|
||||
await once(server, 'listening')
|
||||
|
||||
const client = new Client(`http://localhost:${server.address().port}`)
|
||||
|
||||
client.on('disconnect', (origin) => {
|
||||
console.log(`Disconnected from ${origin}`)
|
||||
})
|
||||
|
||||
try {
|
||||
await client.request({ path: '/', method: 'GET' })
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
} finally {
|
||||
client.close()
|
||||
server.close()
|
||||
}
|
||||
```
|
||||
|
||||
### Event: `'drain'`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `origin` {URL}
|
||||
|
||||
Emitted when the pipeline is no longer busy. See
|
||||
[`Dispatcher` Event: `'drain'`][].
|
||||
|
||||
```mjs
|
||||
import { createServer } from 'node:http'
|
||||
import { Client } from 'undici'
|
||||
import { once } from 'node:events'
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
response.end('Hello, World!')
|
||||
}).listen()
|
||||
|
||||
await once(server, 'listening')
|
||||
|
||||
const client = new Client(`http://localhost:${server.address().port}`)
|
||||
|
||||
client.on('drain', () => {
|
||||
console.log('drain event')
|
||||
client.close()
|
||||
server.close()
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
client.request({ path: '/', method: 'GET' }),
|
||||
client.request({ path: '/', method: 'GET' }),
|
||||
client.request({ path: '/', method: 'GET' })
|
||||
])
|
||||
```
|
||||
|
||||
### Event: `'error'`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `error` {Error}
|
||||
|
||||
Emitted for user errors, such as throwing inside an `onResponseError` handler.
|
||||
|
||||
[RFC 7230, section 6.3.2]: https://tools.ietf.org/html/rfc7230#section-6.3.2
|
||||
[RFC 8305]: https://tools.ietf.org/html/rfc8305#section-5
|
||||
[`Agent`]: Agent.md#class-agent
|
||||
[`ClientStats`]: ClientStats.md
|
||||
[`Connector`]: Connector.md
|
||||
[`Dispatcher` Event: `'connect'`]: Dispatcher.md#event-connect
|
||||
[`Dispatcher` Event: `'disconnect'`]: Dispatcher.md#event-disconnect
|
||||
[`Dispatcher` Event: `'drain'`]: Dispatcher.md#event-drain
|
||||
[`Dispatcher`]: Dispatcher.md#class-dispatcher
|
||||
[`Pool`]: Pool.md#class-pool
|
||||
[`buildConnector`]: Connector.md
|
||||
[`dispatcher.close([callback])`]: Dispatcher.md#dispatcherclosecallback
|
||||
[`dispatcher.connect()`]: Dispatcher.md#dispatcherconnectoptions-callback
|
||||
[`dispatcher.destroy([error[, callback]])`]: Dispatcher.md#dispatcherdestroyerror-callback
|
||||
[`dispatcher.dispatch(options, handler)`]: Dispatcher.md#dispatcherdispatchoptions-handler
|
||||
[`dispatcher.pipeline(options, handler)`]: Dispatcher.md#dispatcherpipelineoptions-handler
|
||||
[`dispatcher.request(options[, callback])`]: Dispatcher.md#dispatcherrequestoptions-callback
|
||||
[`dispatcher.stream(options, factory[, callback])`]: Dispatcher.md#dispatcherstreamoptions-factory-callback
|
||||
[`dispatcher.upgrade(options[, callback])`]: Dispatcher.md#dispatcherupgradeoptions-callback
|
||||
[`tls.connect()`]: https://nodejs.org/api/tls.html#tlsconnectoptions-callback
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
# ClientStats
|
||||
|
||||
<!--introduced_in=v7.9.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/util/stats.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A `ClientStats` is a point-in-time snapshot of the connection and request
|
||||
state of a single [`Client`][]. Instances are not created directly; a fresh
|
||||
snapshot is produced each time the [`client.stats`][] getter is read.
|
||||
|
||||
```mjs
|
||||
import { Client } from 'undici'
|
||||
|
||||
const client = new Client('http://localhost:3000')
|
||||
const stats = client.stats
|
||||
console.log(stats.connected, stats.running)
|
||||
```
|
||||
|
||||
```cjs
|
||||
const { Client } = require('undici')
|
||||
|
||||
const client = new Client('http://localhost:3000')
|
||||
const stats = client.stats
|
||||
console.log(stats.connected, stats.running)
|
||||
```
|
||||
|
||||
## Class: `ClientStats`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.9.0
|
||||
-->
|
||||
|
||||
Captures the connection and request counters of a [`Client`][] at the moment
|
||||
the snapshot is taken. The values are read from the client at construction time
|
||||
and do not update afterwards; read [`client.stats`][] again for current values.
|
||||
|
||||
### `new ClientStats(client)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.9.0
|
||||
-->
|
||||
|
||||
* `client` {Client} The client to read the statistics from.
|
||||
|
||||
Creates a snapshot from the supplied `client`. This constructor is used
|
||||
internally by the [`client.stats`][] getter and is not normally invoked by
|
||||
application code.
|
||||
|
||||
### `clientStats.connected`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.9.0
|
||||
-->
|
||||
|
||||
* Type: {boolean}
|
||||
|
||||
`true` when the client currently has an open socket connection.
|
||||
|
||||
### `clientStats.pending`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.9.0
|
||||
-->
|
||||
|
||||
* Type: {number}
|
||||
|
||||
Number of open socket connections of this client that do not have an active
|
||||
request.
|
||||
|
||||
### `clientStats.running`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.9.0
|
||||
-->
|
||||
|
||||
* Type: {number}
|
||||
|
||||
Number of currently active requests of this client.
|
||||
|
||||
### `clientStats.size`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.9.0
|
||||
-->
|
||||
|
||||
* Type: {number}
|
||||
|
||||
Number of active, pending, or queued requests of this client.
|
||||
|
||||
[`Client`]: Client.md#class-client
|
||||
[`client.stats`]: Client.md#clientstats
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
# Connector
|
||||
|
||||
<!--introduced_in=v4.3.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/core/connect.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
undici opens the underlying socket for every request through a *connector*. By
|
||||
default this is handled internally, so most applications never interact with the
|
||||
connector directly. When a request needs additional control over the socket —
|
||||
for example to inspect a TLS certificate, pin a CA fingerprint, or tunnel the
|
||||
connection — a custom connector can be supplied through the `connect` option of
|
||||
a [`Dispatcher`][].
|
||||
|
||||
Use the `buildConnector` helper to construct a connector with sensible defaults
|
||||
and then wrap it:
|
||||
|
||||
```mjs
|
||||
import { buildConnector } from 'undici'
|
||||
|
||||
const connector = buildConnector({ rejectUnauthorized: false })
|
||||
```
|
||||
|
||||
`buildConnector` accepts the same TLS options as
|
||||
[`tls.connect()`][tls.connect] in addition to the undici-specific options listed
|
||||
below, and returns a [`connector`](#connectoroptions-callback) function that
|
||||
undici invokes for each new connection.
|
||||
|
||||
## `buildConnector([options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.23.4
|
||||
-->
|
||||
|
||||
* `options` {buildConnector.BuildOptions} (optional) Connection options. In
|
||||
addition to every [`tls.connect()`][tls.connect] option, the following fields
|
||||
are supported:
|
||||
* `allowH2` {boolean} Whether to offer HTTP/2 (`h2`) during TLS ALPN
|
||||
negotiation. **Default:** `true`.
|
||||
* `preferH2` {boolean} Only effective together with `allowH2`. When `true`,
|
||||
ALPN is offered as `['h2', 'http/1.1']` (HTTP/2 first) instead of the default
|
||||
`['http/1.1', 'h2']`. Use this when the server selects the ALPN protocol by
|
||||
*client* preference (for example, some load balancers) so that HTTP/2 is
|
||||
negotiated whenever the server supports it. If the server does not support
|
||||
HTTP/2, ALPN transparently falls back to `http/1.1`. **Default:** `false`.
|
||||
* `maxCachedSessions` {number|null} Maximum number of TLS sessions to cache for
|
||||
reuse. Use `0` to disable TLS session caching. Must be a non-negative
|
||||
integer. **Default:** `100`.
|
||||
* `socketPath` {string|null} An IPC endpoint, either a Unix domain socket or a
|
||||
Windows named pipe. **Default:** `null`.
|
||||
* `timeout` {number|null} The connection timeout in milliseconds. A
|
||||
[`ConnectTimeoutError`][] is raised when the socket fails to connect within
|
||||
this period. **Default:** `10e3`.
|
||||
* `port` {number} The port to connect to when one is not provided to the
|
||||
connector. Defaults to `443` for `https:` and `80` otherwise.
|
||||
* `keepAlive` {boolean|null} Whether to enable TCP keep-alive on the socket.
|
||||
**Default:** `true`.
|
||||
* `keepAliveInitialDelay` {number|null} The delay in milliseconds before the
|
||||
first TCP keep-alive probe is sent on an idle socket. **Default:** `60e3`.
|
||||
* `typeOfService` {number|null} The IP Type of Service (ToS) value to set on
|
||||
the socket. **Default:** `null`.
|
||||
* Returns: {buildConnector.connector} A
|
||||
[`connector`](#connectoroptions-callback) function bound to the supplied
|
||||
options.
|
||||
|
||||
Builds a connector function. `maxCachedSessions` must be a non-negative integer
|
||||
or an [`InvalidArgumentError`][] is thrown.
|
||||
|
||||
The returned function may be passed directly as the `connect` option of a
|
||||
[`Dispatcher`][], or wrapped to perform extra validation on each socket before it
|
||||
is handed back to undici.
|
||||
|
||||
```mjs
|
||||
import { Client, buildConnector } from 'undici'
|
||||
|
||||
const connector = buildConnector({ rejectUnauthorized: false })
|
||||
|
||||
const client = new Client('https://localhost:3000', {
|
||||
connect (opts, cb) {
|
||||
connector(opts, (err, socket) => {
|
||||
if (err) {
|
||||
cb(err)
|
||||
} else if (/* assertion */ false) {
|
||||
socket.destroy()
|
||||
cb(new Error('kaboom'))
|
||||
} else {
|
||||
cb(null, socket)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## `connector(options, callback)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.3.0
|
||||
-->
|
||||
|
||||
* `options` {Object} Per-connection options provided by undici.
|
||||
* `hostname` {string} The host to connect to.
|
||||
* `host` {string} (optional) The value of the request `Host` header, used to
|
||||
derive the TLS `servername` when one is not supplied.
|
||||
* `protocol` {string} The request protocol, for example `'https:'` or
|
||||
`'http:'`. A value of `'https:'` establishes a TLS connection.
|
||||
* `port` {string} The port to connect to. Defaults to `443` for `https:` and
|
||||
`80` otherwise.
|
||||
* `servername` {string} (optional) The TLS Server Name Indication (SNI) value.
|
||||
Derived from `host` when omitted.
|
||||
* `localAddress` {string|null} (optional) The local address the socket should
|
||||
connect from.
|
||||
* `httpSocket` {Socket} (optional) An existing socket on which to establish the
|
||||
secure connection rather than creating a new one. May only be supplied for a
|
||||
TLS upgrade (`protocol` of `'https:'`).
|
||||
* `callback` {Function} Called once the socket has connected or failed.
|
||||
* `err` {Error|null} The connection error, or `null` on success.
|
||||
* `socket` {Socket|TLSSocket|null} The connected socket, or `null` on failure.
|
||||
* Returns: {Socket|TLSSocket} The socket being established.
|
||||
|
||||
The function returned by [`buildConnector()`](#buildconnectoroptions). undici
|
||||
calls it for every new connection. It opens a [`net.Socket`][] for `http:`
|
||||
targets or a [`tls.TLSSocket`][] for `https:` targets, applies TCP keep-alive and
|
||||
the connect timeout, and invokes `callback` once the socket connects or errors.
|
||||
The socket is also returned synchronously.
|
||||
|
||||
When connecting over TLS, established sessions are cached (subject to
|
||||
`maxCachedSessions`) and reused for subsequent connections to the same
|
||||
`servername` or `hostname`.
|
||||
|
||||
### Example: validate the CA fingerprint
|
||||
|
||||
A custom connector is a convenient place to verify the server certificate before
|
||||
any request is sent over the socket.
|
||||
|
||||
```mjs
|
||||
import { Client, buildConnector } from 'undici'
|
||||
|
||||
const caFingerprint = 'FO:OB:AR'
|
||||
const connector = buildConnector({ rejectUnauthorized: false })
|
||||
|
||||
const client = new Client('https://localhost:3000', {
|
||||
connect (opts, cb) {
|
||||
connector(opts, (err, socket) => {
|
||||
if (err) {
|
||||
cb(err)
|
||||
} else if (getIssuerCertificate(socket).fingerprint256 !== caFingerprint) {
|
||||
socket.destroy()
|
||||
cb(new Error('Fingerprint does not match or malformed certificate'))
|
||||
} else {
|
||||
cb(null, socket)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
client.request({
|
||||
path: '/',
|
||||
method: 'GET'
|
||||
}, (err, data) => {
|
||||
if (err) throw err
|
||||
|
||||
const bufs = []
|
||||
data.body.on('data', (buf) => {
|
||||
bufs.push(buf)
|
||||
})
|
||||
data.body.on('end', () => {
|
||||
console.log(Buffer.concat(bufs).toString('utf8'))
|
||||
client.close()
|
||||
})
|
||||
})
|
||||
|
||||
function getIssuerCertificate (socket) {
|
||||
let certificate = socket.getPeerCertificate(true)
|
||||
while (certificate && Object.keys(certificate).length > 0) {
|
||||
// Invalid certificate.
|
||||
if (certificate.issuerCertificate == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// We have reached the root certificate. In case of self-signed
|
||||
// certificates, `issuerCertificate` may be a circular reference.
|
||||
if (certificate.fingerprint256 === certificate.issuerCertificate.fingerprint256) {
|
||||
break
|
||||
}
|
||||
|
||||
certificate = certificate.issuerCertificate
|
||||
}
|
||||
return certificate
|
||||
}
|
||||
```
|
||||
|
||||
[`ConnectTimeoutError`]: Errors.md#class-connecttimeouterror
|
||||
[`Dispatcher`]: Dispatcher.md#class-dispatcher
|
||||
[`InvalidArgumentError`]: Errors.md#class-invalidargumenterror
|
||||
[`net.Socket`]: https://nodejs.org/api/net.html#class-netsocket
|
||||
[`tls.TLSSocket`]: https://nodejs.org/api/tls.html#class-tlstlssocket
|
||||
[tls.connect]: https://nodejs.org/api/tls.html#tlsconnectoptions-callback
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
# MIME Type Parsing
|
||||
|
||||
<!--introduced_in=v4.13.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/web/fetch/data-url.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
undici exposes helpers for parsing and serializing MIME types according to the
|
||||
WHATWG [MIME Sniffing Standard][]. These are the same primitives undici uses
|
||||
internally to interpret `Content-Type` headers and `data:` URLs.
|
||||
|
||||
Import the functions from `'undici'`:
|
||||
|
||||
```mjs
|
||||
import { parseMIMEType, serializeAMimeType } from 'undici'
|
||||
```
|
||||
|
||||
A parsed MIME type is represented as a plain object with the following shape:
|
||||
|
||||
* `type` {string} The lowercased type, for example `'text'`.
|
||||
* `subtype` {string} The lowercased subtype, for example `'html'`.
|
||||
* `parameters` {Map<string, string>} The parameters, keyed by lowercased
|
||||
parameter name.
|
||||
* `essence` {string} The concatenation of `type`, `'/'`, and `subtype`, for
|
||||
example `'text/html'`.
|
||||
|
||||
## `parseMIMEType(input)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.13.0
|
||||
-->
|
||||
|
||||
* `input` {string} The MIME type string to parse.
|
||||
* Returns: {Object|string} A parsed MIME type record, or the string literal
|
||||
`'failure'` when `input` cannot be parsed.
|
||||
* `type` {string} The lowercased type.
|
||||
* `subtype` {string} The lowercased subtype.
|
||||
* `parameters` {Map<string, string>} The parsed parameters, keyed by
|
||||
lowercased parameter name.
|
||||
* `essence` {string} The MIME type essence, `` `${type}/${subtype}` ``.
|
||||
|
||||
Implements the WHATWG [parse a MIME type][] algorithm. The `type` and `subtype`
|
||||
are lowercased, and only the first occurrence of a duplicate parameter name is
|
||||
retained. When `input` is empty, lacks a `/`, or otherwise violates the grammar,
|
||||
the string literal `'failure'` is returned instead of a record.
|
||||
|
||||
```mjs
|
||||
import { parseMIMEType } from 'undici'
|
||||
|
||||
parseMIMEType('text/html; charset=gbk')
|
||||
// {
|
||||
// type: 'text',
|
||||
// subtype: 'html',
|
||||
// parameters: Map(1) { 'charset' => 'gbk' },
|
||||
// essence: 'text/html'
|
||||
// }
|
||||
|
||||
parseMIMEType('not a mime type')
|
||||
// 'failure'
|
||||
```
|
||||
|
||||
## `serializeAMimeType(mimeType)`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.11.0
|
||||
-->
|
||||
|
||||
* `mimeType` {Object} A MIME type record, as returned by [`parseMIMEType()`][].
|
||||
* `type` {string} The type.
|
||||
* `subtype` {string} The subtype.
|
||||
* `parameters` {Map<string, string>} The parameters to serialize, in
|
||||
insertion order.
|
||||
* `essence` {string} The MIME type essence, `` `${type}/${subtype}` ``.
|
||||
* Returns: {string} The serialized MIME type.
|
||||
|
||||
Implements the WHATWG [serialize a MIME type][] algorithm. The essence is
|
||||
emitted followed by each parameter as `;name=value`. A parameter value that
|
||||
contains characters outside the HTTP token set is quoted, and any `"` or `\`
|
||||
characters within it are escaped.
|
||||
|
||||
```mjs
|
||||
import { serializeAMimeType } from 'undici'
|
||||
|
||||
serializeAMimeType({
|
||||
type: 'text',
|
||||
subtype: 'html',
|
||||
parameters: new Map([['charset', 'gbk']]),
|
||||
essence: 'text/html'
|
||||
})
|
||||
// 'text/html;charset=gbk'
|
||||
```
|
||||
|
||||
[MIME Sniffing Standard]: https://mimesniff.spec.whatwg.org/
|
||||
[`parseMIMEType()`]: #parsemimetypeinput
|
||||
[parse a MIME type]: https://mimesniff.spec.whatwg.org/#parse-a-mime-type
|
||||
[serialize a MIME type]: https://mimesniff.spec.whatwg.org/#serialize-a-mime-type
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
# Cookies
|
||||
|
||||
<!--introduced_in=v5.15.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/web/cookies/index.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A set of helpers for reading and writing HTTP cookies on a [`Headers`][]
|
||||
instance, following the [RFC 6265bis][] cookie parsing and serialization
|
||||
algorithm. They operate on the `Cookie` request header and the `Set-Cookie`
|
||||
response header.
|
||||
|
||||
```mjs
|
||||
import { getCookies, setCookie, deleteCookie, getSetCookies, parseCookie } from 'undici'
|
||||
```
|
||||
|
||||
These functions do not manage a cookie jar or perform any network activity; they
|
||||
only read from and mutate the supplied `Headers` object.
|
||||
|
||||
## `Cookie`
|
||||
|
||||
The `Cookie` object describes a single cookie and its attributes. It is the
|
||||
return shape of [`parseCookie()`][] and [`getSetCookies()`][], and the input
|
||||
shape accepted by [`setCookie()`][].
|
||||
|
||||
* `name` {string} The cookie name.
|
||||
* `value` {string} The cookie value.
|
||||
* `expires` {Date|number} The cookie expiry time, as a `Date` or a Unix
|
||||
timestamp in seconds. (optional)
|
||||
* `maxAge` {number} The number of seconds until the cookie expires. (optional)
|
||||
* `domain` {string} The hosts the cookie is sent to. (optional)
|
||||
* `path` {string} The URL path that must exist for the cookie to be sent.
|
||||
(optional)
|
||||
* `secure` {boolean} Whether the cookie is only sent over HTTPS. (optional)
|
||||
* `httpOnly` {boolean} Whether the cookie is inaccessible to client-side
|
||||
JavaScript. (optional)
|
||||
* `sameSite` {string} Controls whether the cookie is sent with cross-site
|
||||
requests. One of `'Strict'`, `'Lax'`, or `'None'`. (optional)
|
||||
* `unparsed` {string[]} Any attributes that were present in the `Set-Cookie`
|
||||
header but not recognized, each formatted as `` `name=value` ``. (optional)
|
||||
|
||||
## `getCookies(headers)`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* `headers` {Headers} The `Headers` to read the `Cookie` header from.
|
||||
* Returns: {Record<string, string>} A map of cookie names to values.
|
||||
|
||||
Parses the `Cookie` header of `headers` and returns its name/value pairs as a
|
||||
plain object. An empty object is returned when no `Cookie` header is present.
|
||||
|
||||
```mjs
|
||||
import { getCookies, Headers } from 'undici'
|
||||
|
||||
const headers = new Headers({
|
||||
cookie: 'get=cookies; and=attributes'
|
||||
})
|
||||
|
||||
console.log(getCookies(headers)) // { get: 'cookies', and: 'attributes' }
|
||||
```
|
||||
|
||||
## `deleteCookie(headers, name[, attributes])`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* `headers` {Headers} The `Headers` to append the expiring cookie to.
|
||||
* `name` {string} The name of the cookie to delete.
|
||||
* `attributes` {Object} (optional)
|
||||
* `path` {string} The path the cookie was scoped to. **Default:** `null`.
|
||||
* `domain` {string} The domain the cookie was scoped to. **Default:** `null`.
|
||||
* Returns: {undefined}
|
||||
|
||||
Appends a `Set-Cookie` header to `headers` that expires the named cookie by
|
||||
setting its expiry time to the Unix epoch, causing a client to delete it on
|
||||
receipt. The optional `path` and `domain` must match those of the cookie being
|
||||
removed.
|
||||
|
||||
```mjs
|
||||
import { deleteCookie, Headers } from 'undici'
|
||||
|
||||
const headers = new Headers()
|
||||
deleteCookie(headers, 'name')
|
||||
|
||||
console.log(headers.get('set-cookie')) // name=; Expires=Thu, 01 Jan 1970 00:00:00 GMT
|
||||
```
|
||||
|
||||
## `getSetCookies(headers)`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* `headers` {Headers} The `Headers` to read the `Set-Cookie` headers from.
|
||||
* Returns: {Cookie[]} The parsed cookies, or an empty array when none are
|
||||
present.
|
||||
|
||||
Parses every `Set-Cookie` header of `headers` and returns the resulting
|
||||
[`Cookie`][] objects. A header that fails to parse yields a `null` entry at its
|
||||
position in the returned array.
|
||||
|
||||
```mjs
|
||||
import { getSetCookies, Headers } from 'undici'
|
||||
|
||||
const headers = new Headers({ 'set-cookie': 'undici=getSetCookies; Secure' })
|
||||
|
||||
console.log(getSetCookies(headers))
|
||||
// [
|
||||
// {
|
||||
// name: 'undici',
|
||||
// value: 'getSetCookies',
|
||||
// secure: true
|
||||
// }
|
||||
// ]
|
||||
```
|
||||
|
||||
## `setCookie(headers, cookie)`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* `headers` {Headers} The `Headers` to append the `Set-Cookie` header to.
|
||||
* `cookie` {Cookie} The cookie to serialize. See [`Cookie`][].
|
||||
* Returns: {undefined}
|
||||
|
||||
Serializes `cookie` and appends it to the `Set-Cookie` header of `headers`. When
|
||||
the cookie name is empty, serialization yields no value and no header is
|
||||
appended.
|
||||
|
||||
```mjs
|
||||
import { setCookie, Headers } from 'undici'
|
||||
|
||||
const headers = new Headers()
|
||||
setCookie(headers, { name: 'undici', value: 'setCookie' })
|
||||
|
||||
console.log(headers.get('Set-Cookie')) // undici=setCookie
|
||||
```
|
||||
|
||||
## `parseCookie(cookie)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `cookie` {string} A `Set-Cookie` header value.
|
||||
* Returns: {Cookie|null} The parsed cookie, or `null` if `cookie` is invalid.
|
||||
|
||||
Parses a single `Set-Cookie` header value into a [`Cookie`][] object. `null` is
|
||||
returned when the value contains control characters or otherwise cannot be
|
||||
parsed.
|
||||
|
||||
```mjs
|
||||
import { parseCookie } from 'undici'
|
||||
|
||||
console.log(parseCookie('undici=getSetCookies; Secure; SameSite=Lax'))
|
||||
// {
|
||||
// name: 'undici',
|
||||
// value: 'getSetCookies',
|
||||
// secure: true,
|
||||
// sameSite: 'Lax'
|
||||
// }
|
||||
```
|
||||
|
||||
The cookie value is returned exactly as it appears in the header; percent-encoded
|
||||
sequences such as `%20` or `%0D%0A` are not decoded. The `sameSite` attribute is
|
||||
only set when the value is a case-insensitive match for `Strict`, `Lax`, or
|
||||
`None`.
|
||||
|
||||
[RFC 6265bis]: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis
|
||||
[`Cookie`]: #cookie
|
||||
[`Headers`]: https://developer.mozilla.org/en-US/docs/Web/API/Headers
|
||||
[`getSetCookies()`]: #getsetcookiesheaders
|
||||
[`parseCookie()`]: #parsecookiecookie
|
||||
[`setCookie()`]: #setcookieheaders-cookie
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
# Debug
|
||||
|
||||
<!--type=misc-->
|
||||
|
||||
Undici emits human-readable debug logs through Node.js' built-in
|
||||
[`util.debuglog()`][]. The logs are disabled by default and are turned on by
|
||||
listing one or more namespaces in the `NODE_DEBUG` environment variable. Because
|
||||
`util.debuglog()` reads `NODE_DEBUG` once, when the namespace is first used, the
|
||||
variable must be set before the process starts.
|
||||
|
||||
Each log line is prefixed with the namespace (uppercased) and the process id,
|
||||
for example `UNDICI 16241: connecting to nodejs.org using https:h1`. Multiple
|
||||
namespaces can be enabled at once by separating them with commas, for example
|
||||
`NODE_DEBUG=undici,websocket`.
|
||||
|
||||
The following sections describe the namespaces exposed by undici.
|
||||
|
||||
## `undici`
|
||||
|
||||
Enables debug logs for the core undici library, covering connection setup,
|
||||
request dispatch, responses, and trailers. The `fetch` and `websocket` APIs are
|
||||
built on top of this core, so enabling `undici` also surfaces activity that
|
||||
originates from them.
|
||||
|
||||
```console
|
||||
$ NODE_DEBUG=undici node script.js
|
||||
UNDICI 16241: connecting to nodejs.org using https:h1
|
||||
UNDICI 16241: connecting to nodejs.org using https:h1
|
||||
UNDICI 16241: connected to nodejs.org using https:h1
|
||||
UNDICI 16241: sending request to GET https://nodejs.org/
|
||||
UNDICI 16241: received response to GET https://nodejs.org/ - HTTP 307
|
||||
UNDICI 16241: connecting to nodejs.org using https:h1
|
||||
UNDICI 16241: trailers received from GET https://nodejs.org/
|
||||
UNDICI 16241: connected to nodejs.org using https:h1
|
||||
UNDICI 16241: sending request to GET https://nodejs.org/en
|
||||
UNDICI 16241: received response to GET https://nodejs.org/en - HTTP 200
|
||||
UNDICI 16241: trailers received from GET https://nodejs.org/en
|
||||
```
|
||||
|
||||
## `fetch`
|
||||
|
||||
Enables debug logs scoped to the `fetch` API. The output mirrors the `undici`
|
||||
namespace but is emitted only for requests made through [`fetch()`][], which
|
||||
makes it useful for isolating `fetch` traffic from other undici activity.
|
||||
|
||||
```console
|
||||
$ NODE_DEBUG=fetch node script.js
|
||||
FETCH 16241: connecting to nodejs.org using https:h1
|
||||
FETCH 16241: connecting to nodejs.org using https:h1
|
||||
FETCH 16241: connected to nodejs.org using https:h1
|
||||
FETCH 16241: sending request to GET https://nodejs.org/
|
||||
FETCH 16241: received response to GET https://nodejs.org/ - HTTP 307
|
||||
FETCH 16241: connecting to nodejs.org using https:h1
|
||||
FETCH 16241: trailers received from GET https://nodejs.org/
|
||||
FETCH 16241: connected to nodejs.org using https:h1
|
||||
FETCH 16241: sending request to GET https://nodejs.org/en
|
||||
FETCH 16241: received response to GET https://nodejs.org/en - HTTP 200
|
||||
FETCH 16241: trailers received from GET https://nodejs.org/en
|
||||
```
|
||||
|
||||
## `websocket`
|
||||
|
||||
Enables debug logs scoped to the [`WebSocket`][] API, covering the opening
|
||||
handshake and connection lifecycle. When the `undici` or `fetch` namespace is
|
||||
also enabled, some lines may appear under both namespaces because the handshake
|
||||
is performed through the core library.
|
||||
|
||||
```console
|
||||
$ NODE_DEBUG=websocket node script.js
|
||||
WEBSOCKET 18309: connecting to echo.websocket.org using https:h1
|
||||
WEBSOCKET 18309: connected to echo.websocket.org using https:h1
|
||||
WEBSOCKET 18309: sending request to GET https://echo.websocket.org/
|
||||
WEBSOCKET 18309: connection opened <ip_address>
|
||||
```
|
||||
|
||||
[`WebSocket`]: WebSocket.md#class-websocket
|
||||
[`fetch()`]: Fetch.md
|
||||
[`util.debuglog()`]: https://nodejs.org/api/util.html#utildebuglogsection-callback
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
# Diagnostics Channel Support
|
||||
|
||||
<!--introduced_in=v6.3.0-->
|
||||
<!--type=misc-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
Undici is instrumented with Node.js' built-in [`diagnostics_channel`][] module.
|
||||
It is the preferred way to observe undici's internal behaviour without patching
|
||||
the library, and it backs the human-readable [debug logs][Debug].
|
||||
|
||||
Each integration point is exposed as a named channel. To observe an event,
|
||||
obtain the channel by name and subscribe to it:
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:request:create').subscribe(({ request }) => {
|
||||
console.log(request.method, request.origin, request.path)
|
||||
})
|
||||
```
|
||||
|
||||
```cjs
|
||||
const diagnosticsChannel = require('node:diagnostics_channel')
|
||||
|
||||
diagnosticsChannel.channel('undici:request:create').subscribe(({ request }) => {
|
||||
console.log(request.method, request.origin, request.path)
|
||||
})
|
||||
```
|
||||
|
||||
The message published to a channel is always a single object whose shape depends
|
||||
on the channel. The sections below describe every channel and the object it
|
||||
publishes. Subscriber callbacks run synchronously inside undici, so they should
|
||||
be fast and must not throw.
|
||||
|
||||
The same `request` object is shared across all `undici:request:*` channels for a
|
||||
given request, so a subscriber may correlate the lifecycle of one request by
|
||||
identity.
|
||||
|
||||
## Event: `'undici:request:create'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.3.0
|
||||
-->
|
||||
|
||||
Published when a new outgoing request is created, before it is dispatched.
|
||||
|
||||
* `message` {Object}
|
||||
* `request` {Object} The request being created.
|
||||
* `origin` {string|URL} The origin the request is sent to.
|
||||
* `completed` {boolean} Whether the request has completed. `false` at this
|
||||
point.
|
||||
* `method` {string} The HTTP method, e.g. `'GET'`.
|
||||
* `path` {string} The request path, including any query string.
|
||||
* `headers` {string[]} The request headers as a flat array of strings,
|
||||
alternating between header name and value.
|
||||
|
||||
The `request` object also exposes an `addHeader(name, value)` method that
|
||||
appends a header before the request is sent.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:request:create').subscribe(({ request }) => {
|
||||
console.log('origin', request.origin)
|
||||
console.log('completed', request.completed)
|
||||
console.log('method', request.method)
|
||||
console.log('path', request.path)
|
||||
console.log('headers', request.headers)
|
||||
request.addHeader('hello', 'world')
|
||||
})
|
||||
```
|
||||
|
||||
A request is only loosely bound to a given socket at this stage.
|
||||
|
||||
## Event: `'undici:request:bodyChunkSent'`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.11.0
|
||||
-->
|
||||
|
||||
Published each time a chunk of the request body is written to the socket.
|
||||
|
||||
* `message` {Object}
|
||||
* `request` {Object} The same object published by
|
||||
[`'undici:request:create'`][].
|
||||
* `chunk` {Uint8Array|string} The body chunk being sent.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:request:bodyChunkSent').subscribe(({ request, chunk }) => {
|
||||
console.log('sent', chunk.length, 'bytes')
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:request:bodySent'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.3.0
|
||||
-->
|
||||
|
||||
Published after the request body has been fully sent.
|
||||
|
||||
* `message` {Object}
|
||||
* `request` {Object} The same object published by
|
||||
[`'undici:request:create'`][].
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:request:bodySent').subscribe(({ request }) => {
|
||||
// request is the same object as in 'undici:request:create'
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:request:headers'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.3.0
|
||||
-->
|
||||
|
||||
Published after the response headers have been received. This includes a
|
||||
successful CONNECT or protocol upgrade response.
|
||||
|
||||
* `message` {Object}
|
||||
* `request` {Object} The same object published by
|
||||
[`'undici:request:create'`][].
|
||||
* `response` {Object} The response being received.
|
||||
* `statusCode` {number} The HTTP status code.
|
||||
* `statusText` {string} The HTTP status message.
|
||||
* `headers` {Buffer[]|Object} HTTP/1.1 response headers are an array of
|
||||
buffers alternating between header name and value. HTTP/2 response
|
||||
headers are an object.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:request:headers').subscribe(({ request, response }) => {
|
||||
console.log('statusCode', response.statusCode)
|
||||
console.log(response.statusText)
|
||||
console.log(response.headers)
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:request:bodyChunkReceived'`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.11.0
|
||||
-->
|
||||
|
||||
Published each time a chunk of the response body is received.
|
||||
|
||||
* `message` {Object}
|
||||
* `request` {Object} The same object published by
|
||||
[`'undici:request:create'`][].
|
||||
* `chunk` {Buffer} The body chunk that was received.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:request:bodyChunkReceived').subscribe(({ request, chunk }) => {
|
||||
console.log('received', chunk.length, 'bytes')
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:request:trailers'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.3.0
|
||||
-->
|
||||
|
||||
Published once the response has fully completed. After an upgraded socket has
|
||||
been passed to the request handler, this event is published with an empty
|
||||
`trailers` array.
|
||||
|
||||
* `message` {Object}
|
||||
* `request` {Object} The same object published by
|
||||
[`'undici:request:create'`][]. `request.completed` is now `true`.
|
||||
* `trailers` {Buffer[]} The raw response trailers as an array of buffers,
|
||||
alternating between header name and value.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:request:trailers').subscribe(({ request, trailers }) => {
|
||||
console.log('completed', request.completed)
|
||||
console.log(trailers.map((x) => x.toString()))
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:request:error'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.3.0
|
||||
-->
|
||||
|
||||
Published when the request is about to error, before the error is surfaced to
|
||||
the caller.
|
||||
|
||||
* `message` {Object}
|
||||
* `request` {Object} The same object published by
|
||||
[`'undici:request:create'`][].
|
||||
* `error` {Error} The error the request is about to fail with.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:request:error').subscribe(({ request, error }) => {
|
||||
console.error(error)
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:client:sendHeaders'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.3.0
|
||||
changes:
|
||||
- version: v7.0.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/3585
|
||||
description: Refactor the diagnostics channel internals.
|
||||
-->
|
||||
|
||||
Published immediately before the first byte of the request is written to the
|
||||
socket. The headers are published exactly as they will be sent to the server, in
|
||||
raw form.
|
||||
|
||||
* `message` {Object}
|
||||
* `request` {Object} The same object published by
|
||||
[`'undici:request:create'`][].
|
||||
* `headers` {string} The request headers as a single raw string, with lines
|
||||
separated by `\r\n`.
|
||||
* `socket` {net.Socket} The socket the request is written to.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:client:sendHeaders').subscribe(({ request, headers, socket }) => {
|
||||
console.log(`Full headers list: ${headers.split('\r\n')}`)
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:client:beforeConnect'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.3.0
|
||||
changes:
|
||||
- version: v7.0.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/3585
|
||||
description: Refactor the diagnostics channel internals.
|
||||
-->
|
||||
|
||||
Published before a new connection is opened for any request. This event cannot be
|
||||
attributed to a specific request, because connections are pooled and shared.
|
||||
|
||||
* `message` {Object}
|
||||
* `connectParams` {Object} The parameters used to open the connection.
|
||||
* `host` {string} The connection host, including the port if present.
|
||||
* `hostname` {string} The connection hostname.
|
||||
* `protocol` {string} The protocol, e.g. `'https:'`.
|
||||
* `port` {string} The connection port.
|
||||
* `servername` {string|null} The TLS server name, or `null`.
|
||||
* `connector` {Function} The function that creates the socket.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:client:beforeConnect').subscribe(({ connectParams, connector }) => {
|
||||
const { host, hostname, protocol, port, servername } = connectParams
|
||||
// connector is the function that creates the socket
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:client:connected'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.3.0
|
||||
changes:
|
||||
- version: v7.0.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/3585
|
||||
description: Refactor the diagnostics channel internals.
|
||||
-->
|
||||
|
||||
Published after a connection has been successfully established.
|
||||
|
||||
* `message` {Object}
|
||||
* `socket` {net.Socket} The newly connected socket.
|
||||
* `connectParams` {Object} The parameters used to open the connection. See
|
||||
[`'undici:client:beforeConnect'`][] for the field descriptions.
|
||||
* `connector` {Function} The function that created the socket.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:client:connected').subscribe(({ socket, connectParams, connector }) => {
|
||||
const { host, hostname, protocol, port, servername } = connectParams
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:client:connectError'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.3.0
|
||||
changes:
|
||||
- version: v7.0.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/3585
|
||||
description: Refactor the diagnostics channel internals.
|
||||
-->
|
||||
|
||||
Published when a connection could not be established.
|
||||
|
||||
* `message` {Object}
|
||||
* `error` {Error} The error that prevented the connection.
|
||||
* `socket` {net.Socket} The socket associated with the failed attempt.
|
||||
* `connectParams` {Object} The parameters used to open the connection. See
|
||||
[`'undici:client:beforeConnect'`][] for the field descriptions.
|
||||
* `connector` {Function} The function that created the socket.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:client:connectError').subscribe(({ error, socket, connectParams, connector }) => {
|
||||
console.error(`Connect failed with ${error.message}`)
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:websocket:open'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.3.0
|
||||
-->
|
||||
|
||||
Published after a [`WebSocket`][] has successfully connected to a server.
|
||||
|
||||
* `message` {Object}
|
||||
* `address` {Object|null} The remote socket address, as returned by
|
||||
[`socket.address()`][], or `null` if it cannot be determined. When present,
|
||||
it contains `address`, `family`, and `port`.
|
||||
* `protocol` {string} The negotiated subprotocol.
|
||||
* `extensions` {string} The negotiated extensions.
|
||||
* `websocket` {WebSocket} The `WebSocket` instance.
|
||||
* `handshakeResponse` {Object} The HTTP response that upgraded the connection.
|
||||
* `status` {number} The HTTP status code: `101` for an HTTP/1.1 upgrade, or
|
||||
`200` for an HTTP/2 extended `CONNECT`.
|
||||
* `statusText` {string} The HTTP status message: `'Switching Protocols'` for
|
||||
HTTP/1.1, and commonly `'OK'` for HTTP/2.
|
||||
* `headers` {Object} The response headers from the server. The `upgrade` and
|
||||
`connection` headers are only present for HTTP/1.1 handshakes.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:websocket:open').subscribe(({
|
||||
address,
|
||||
protocol,
|
||||
extensions,
|
||||
websocket,
|
||||
handshakeResponse
|
||||
}) => {
|
||||
console.log(address)
|
||||
console.log(protocol)
|
||||
console.log(extensions)
|
||||
console.log(handshakeResponse.status)
|
||||
console.log(handshakeResponse.headers)
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:websocket:close'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.3.0
|
||||
-->
|
||||
|
||||
Published after the [`WebSocket`][] connection has closed.
|
||||
|
||||
* `message` {Object}
|
||||
* `websocket` {WebSocket} The `WebSocket` instance.
|
||||
* `code` {number} The closing status code.
|
||||
* `reason` {string} The closing reason.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:websocket:close').subscribe(({ websocket, code, reason }) => {
|
||||
console.log(code, reason)
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:websocket:socket_error'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.3.0
|
||||
-->
|
||||
|
||||
Published when the underlying WebSocket socket experiences an error. Unlike the
|
||||
other WebSocket channels, the published message is the error itself.
|
||||
|
||||
* `error` {Error} The socket error.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:websocket:socket_error').subscribe((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:websocket:ping'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.3.0
|
||||
-->
|
||||
|
||||
Published after the client receives a ping frame.
|
||||
|
||||
* `message` {Object}
|
||||
* `payload` {Buffer|undefined} The optional application data carried by the
|
||||
frame.
|
||||
* `websocket` {WebSocket} The `WebSocket` instance.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:websocket:ping').subscribe(({ payload, websocket }) => {
|
||||
console.log(payload)
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:websocket:pong'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.3.0
|
||||
-->
|
||||
|
||||
Published after the client receives a pong frame.
|
||||
|
||||
* `message` {Object}
|
||||
* `payload` {Buffer|undefined} The optional application data carried by the
|
||||
frame.
|
||||
* `websocket` {WebSocket} The `WebSocket` instance.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:websocket:pong').subscribe(({ payload, websocket }) => {
|
||||
console.log(payload)
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:proxy:connected'`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
Published after a [`ProxyAgent`][] has established a tunnel to the proxy server.
|
||||
|
||||
* `message` {Object}
|
||||
* `socket` {net.Socket} The socket connected to the proxy.
|
||||
* `connectParams` {Object} The parameters used to establish the tunnel.
|
||||
* `origin` {string|URL} The proxy origin.
|
||||
* `port` {string} The proxy port.
|
||||
* `path` {string} The requested tunnel target, e.g. `'example.com:443'`.
|
||||
* `signal` {AbortSignal|undefined} The signal associated with the request.
|
||||
* `headers` {Object} The headers sent in the `CONNECT` request.
|
||||
* `servername` {string|undefined} The TLS server name used for the proxy.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:proxy:connected').subscribe(({ socket, connectParams }) => {
|
||||
const { origin, port, path, signal, headers, servername } = connectParams
|
||||
})
|
||||
```
|
||||
|
||||
## Event: `'undici:request:pending-requests'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.3.0
|
||||
-->
|
||||
|
||||
Published when the [`dedupe`][] interceptor's set of pending requests changes.
|
||||
The interceptor coalesces concurrent identical requests so that only one reaches
|
||||
the origin; this channel exposes that bookkeeping for monitoring and debugging.
|
||||
|
||||
* `message` {Object}
|
||||
* `type` {string} Either `'added'` when a new pending request is registered, or
|
||||
`'removed'` when a pending request completes, whether successfully or with an
|
||||
error.
|
||||
* `size` {number} The number of pending requests after the change.
|
||||
* `key` {string} The deduplication key for the request, composed of the origin,
|
||||
method, path, and request headers.
|
||||
|
||||
```mjs
|
||||
import diagnosticsChannel from 'node:diagnostics_channel'
|
||||
|
||||
diagnosticsChannel.channel('undici:request:pending-requests').subscribe(({ type, size, key }) => {
|
||||
if (type === 'added') {
|
||||
console.log(`New pending request: ${key} (${size} total pending)`)
|
||||
} else {
|
||||
console.log(`Request completed: ${key} (${size} remaining)`)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
This is useful for verifying that deduplication is working, monitoring the number
|
||||
of concurrent in-flight requests, and debugging deduplication behaviour in
|
||||
production.
|
||||
|
||||
[`'undici:client:beforeConnect'`]: #event-undiciclientbeforeconnect
|
||||
[`'undici:request:create'`]: #event-undicirequestcreate
|
||||
[`'undici:request:headers'`]: #event-undicirequestheaders
|
||||
[`ProxyAgent`]: ProxyAgent.md#class-proxyagent
|
||||
[`WebSocket`]: WebSocket.md#class-websocket
|
||||
[`dedupe`]: Dispatcher.md#dedupe
|
||||
[`diagnostics_channel`]: https://nodejs.org/api/diagnostics_channel.html
|
||||
[`socket.address()`]: https://nodejs.org/api/net.html#socketaddress
|
||||
[Debug]: Debug.md
|
||||
+836
@@ -0,0 +1,836 @@
|
||||
# Dispatcher
|
||||
|
||||
<!--introduced_in=v4.0.0-->
|
||||
|
||||
<!--type=module-->
|
||||
|
||||
<!-- source_link=lib/dispatcher/dispatcher.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
`Dispatcher` is the core abstraction used to dispatch HTTP requests in undici.
|
||||
It extends [`EventEmitter`][] and defines the low-level [`dispatch()`][] contract
|
||||
along with the higher-level [`request()`][], [`stream()`][], [`pipeline()`][],
|
||||
[`connect()`][], and [`upgrade()`][] methods that every concrete dispatcher
|
||||
provides.
|
||||
|
||||
`Dispatcher` itself is abstract: its [`dispatch()`][], [`close()`][], and
|
||||
[`destroy()`][] methods throw `Error: not implemented`. Concrete dispatchers such
|
||||
as [`Client`][], [`Pool`][], [`BalancedPool`][], and [`Agent`][] implement the
|
||||
contract and are what application code instantiates. Requests are not guaranteed
|
||||
to be dispatched in the order in which they are invoked.
|
||||
|
||||
```mjs
|
||||
import { Dispatcher, Agent } from 'undici'
|
||||
|
||||
const dispatcher = new Agent()
|
||||
console.log(dispatcher instanceof Dispatcher) // true
|
||||
```
|
||||
|
||||
## Class: `Dispatcher`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Extends: {EventEmitter}
|
||||
|
||||
Base dispatcher class. Concrete dispatchers extend it and implement the
|
||||
[`dispatch()`][] method on which the other request helpers are built.
|
||||
|
||||
### `dispatcher.close([callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `callback` {Function} (optional) Invoked once the dispatcher is closed.
|
||||
* `error` {Error|null}
|
||||
* Returns: {Promise} A `Promise` is returned only when `callback` is omitted. It
|
||||
resolves with `void` once the dispatcher is closed.
|
||||
|
||||
Closes the dispatcher and gracefully waits for enqueued requests to complete
|
||||
before resolving or invoking `callback`.
|
||||
|
||||
```mjs
|
||||
import { createServer } from 'node:http'
|
||||
import { Client } from 'undici'
|
||||
import { once } from 'node:events'
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
response.end('undici')
|
||||
}).listen()
|
||||
|
||||
await once(server, 'listening')
|
||||
|
||||
const client = new Client(`http://localhost:${server.address().port}`)
|
||||
|
||||
try {
|
||||
const { body } = await client.request({ path: '/', method: 'GET' })
|
||||
body.setEncoding('utf8')
|
||||
body.on('data', console.log)
|
||||
} catch (error) {}
|
||||
|
||||
await client.close()
|
||||
|
||||
console.log('Client closed')
|
||||
server.close()
|
||||
```
|
||||
|
||||
### `dispatcher.connect(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `options` {Object}
|
||||
* `origin` {string|URL} The origin to connect to.
|
||||
* `path` {string} The request path.
|
||||
* `headers` {UndiciHeaders} Request headers. **Default:** `null`.
|
||||
* `signal` {AbortSignal|EventEmitter|null} A signal used to abort the request.
|
||||
**Default:** `null`.
|
||||
* `opaque` {any} An opaque value passed through to the returned `ConnectData`.
|
||||
* `responseHeaders` {string|null} Set to `'raw'` to return the response headers
|
||||
as a raw array instead of an object. **Default:** `null`.
|
||||
* `callback` {Function} (optional) Invoked when the connection is established.
|
||||
* `error` {Error|null}
|
||||
* `data` {Object}
|
||||
* `statusCode` {number}
|
||||
* `headers` {Object} The response headers.
|
||||
* `socket` {Duplex} The established socket.
|
||||
* `opaque` {any} The `opaque` value passed in `options`.
|
||||
* Returns: {Promise} A `Promise` is returned only when `callback` is omitted. It
|
||||
resolves with the `data` object described above.
|
||||
|
||||
Starts two-way communications with the requested resource using the
|
||||
[HTTP `CONNECT`][] method.
|
||||
|
||||
```mjs
|
||||
import { createServer } from 'node:http'
|
||||
import { Client } from 'undici'
|
||||
import { once } from 'node:events'
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
throw new Error('should never get here')
|
||||
}).listen()
|
||||
|
||||
server.on('connect', (req, socket, head) => {
|
||||
socket.write('HTTP/1.1 200 Connection established\r\n\r\n')
|
||||
|
||||
let data = head.toString()
|
||||
socket.on('data', (buf) => {
|
||||
data += buf.toString()
|
||||
})
|
||||
|
||||
socket.on('end', () => {
|
||||
socket.end(data)
|
||||
})
|
||||
})
|
||||
|
||||
await once(server, 'listening')
|
||||
|
||||
const client = new Client(`http://localhost:${server.address().port}`)
|
||||
|
||||
try {
|
||||
const { socket } = await client.connect({ path: '/' })
|
||||
const wanted = 'Body'
|
||||
let data = ''
|
||||
socket.on('data', d => { data += d })
|
||||
socket.on('end', () => {
|
||||
console.log(`Data received: ${data.toString()} | Data wanted: ${wanted}`)
|
||||
client.close()
|
||||
server.close()
|
||||
})
|
||||
socket.write(wanted)
|
||||
socket.end()
|
||||
} catch (error) {}
|
||||
```
|
||||
|
||||
### `dispatcher.destroy([error[, callback]])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `error` {Error|null} (optional) The error with which pending requests are
|
||||
rejected. **Default:** `null`.
|
||||
* `callback` {Function} (optional) Invoked once the socket is closed.
|
||||
* Returns: {Promise} A `Promise` is returned only when `callback` is omitted. It
|
||||
resolves with `void` once the dispatcher is destroyed.
|
||||
|
||||
Destroys the dispatcher abruptly with the given `error`. All pending and running
|
||||
requests are asynchronously aborted and rejected. Because the operation is
|
||||
dispatched asynchronously, dispatched requests may still make some progress
|
||||
before being aborted.
|
||||
|
||||
Both arguments are optional, so the method can be called in four ways:
|
||||
|
||||
```mjs
|
||||
dispatcher.destroy() // -> Promise
|
||||
dispatcher.destroy(new Error()) // -> Promise
|
||||
dispatcher.destroy(() => {}) // -> void
|
||||
dispatcher.destroy(new Error(), () => {}) // -> void
|
||||
```
|
||||
|
||||
```mjs
|
||||
import { createServer } from 'node:http'
|
||||
import { Client } from 'undici'
|
||||
import { once } from 'node:events'
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
response.end()
|
||||
}).listen()
|
||||
|
||||
await once(server, 'listening')
|
||||
|
||||
const client = new Client(`http://localhost:${server.address().port}`)
|
||||
|
||||
try {
|
||||
const request = client.request({ path: '/', method: 'GET' })
|
||||
client.destroy()
|
||||
.then(() => {
|
||||
console.log('Client destroyed')
|
||||
server.close()
|
||||
})
|
||||
await request
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
```
|
||||
|
||||
### `dispatcher.dispatch(options, handler)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `options` {Object}
|
||||
* `origin` {string|URL} (optional) The origin to dispatch the request to.
|
||||
* `path` {string} The request path.
|
||||
* `method` {string} The HTTP method, e.g. `'GET'` or `'POST'`.
|
||||
* `body` {string|Buffer|Uint8Array|Readable|FormData|null} The request body.
|
||||
**Default:** `null`.
|
||||
* `headers` {UndiciHeaders} Request headers. **Default:** `null`.
|
||||
* `query` {Object} Query string parameters embedded into the request URL. Both
|
||||
keys and values are encoded with `encodeURIComponent`. **Default:** `null`.
|
||||
* `idempotent` {boolean} Whether the request can be safely retried. If `false`,
|
||||
the request is not sent until all preceding requests in the pipeline have
|
||||
completed. **Default:** `true` when `method` is `'HEAD'` or `'GET'`.
|
||||
* `blocking` {boolean} Whether the response is expected to block the pipeline.
|
||||
When `true`, further pipelining on the same connection is avoided until
|
||||
headers have been received. **Default:** `method !== 'HEAD'`.
|
||||
* `typeOfService` {number|null} The IP Type of Service (ToS) value for the
|
||||
request socket. Must be an integer between `0` and `255`. **Default:** `0`.
|
||||
* `upgrade` {boolean|string|null} Upgrades the request, e.g. to `'Websocket'`.
|
||||
**Default:** `null`, or the request method when it is `'CONNECT'`.
|
||||
* `headersTimeout` {number|null} The time, in milliseconds, the parser waits to
|
||||
receive the complete HTTP headers. Defaults to 300 seconds.
|
||||
* `bodyTimeout` {number|null} The time, in milliseconds, after which the request
|
||||
times out while receiving body data. Monitors the time between body chunks.
|
||||
Use `0` to disable it entirely. Defaults to 300 seconds.
|
||||
HTTP/1.1 headers/body parser timeouts are not guaranteed to fire with exact
|
||||
millisecond precision: delays up to 1000ms use native timers, while larger
|
||||
delays use undici's lower-overhead fast timers with a target resolution
|
||||
around 500ms.
|
||||
* `reset` {boolean} Whether the request should establish a keep-alive
|
||||
connection. **Default:** `false`.
|
||||
* `expectContinue` {boolean} For HTTP/2, appends the `expect: 100-continue`
|
||||
header and halts the request body until a `100 Continue` is received from the
|
||||
remote server. **Default:** `false`.
|
||||
* `handler` {Object} A set of callbacks invoked over the request lifecycle.
|
||||
* `onRequestStart` {Function} Invoked before the request is dispatched on the
|
||||
socket. May be invoked multiple times when the request is retried.
|
||||
* `controller` {DispatchController}
|
||||
* `context` {any}
|
||||
* `onRequestUpgrade` {Function} (optional) Invoked when the request is upgraded.
|
||||
Required when `options.upgrade` is set or `options.method` is `'CONNECT'`.
|
||||
* `controller` {DispatchController}
|
||||
* `statusCode` {number}
|
||||
* `headers` {Object}
|
||||
* `socket` {Duplex}
|
||||
* `onResponseStart` {Function} (optional) Invoked when the status code and
|
||||
headers have been received. May be invoked multiple times for 1xx
|
||||
informational responses. Not used for `upgrade` requests.
|
||||
* `controller` {DispatchController}
|
||||
* `statusCode` {number}
|
||||
* `headers` {Object}
|
||||
* `statusMessage` {string} (optional)
|
||||
* `onResponseData` {Function} (optional) Invoked when response payload data is
|
||||
received. Not used for `upgrade` requests.
|
||||
* `controller` {DispatchController}
|
||||
* `chunk` {Buffer}
|
||||
* `onResponseEnd` {Function} (optional) Invoked when the response payload and
|
||||
trailers have been received and the request has completed. Not used for
|
||||
`upgrade` requests.
|
||||
* `controller` {DispatchController}
|
||||
* `trailers` {Object}
|
||||
* `onResponseError` {Function} (optional) Invoked when an error occurs. Must not
|
||||
throw.
|
||||
* `controller` {DispatchController}
|
||||
* `error` {Error}
|
||||
* `onResponseStarted` {Function} (optional) Invoked when the response is
|
||||
received, before headers have been read.
|
||||
* `onBodySent` {Function} (optional) Invoked when a chunk of the request body is
|
||||
sent. May be invoked multiple times for chunked requests.
|
||||
* `chunk` {Buffer}
|
||||
* `onRequestSent` {Function} (optional) Invoked after the request body is fully
|
||||
sent.
|
||||
* Returns: {boolean} `false` when the dispatcher is busy and further `dispatch()`
|
||||
calls will make no progress until the [`'drain'`][] event is emitted.
|
||||
|
||||
This is the low-level API on which all the higher-level methods are built. This
|
||||
API is expected to evolve through semver-major versions and is less stable than
|
||||
the higher-level methods. It is primarily intended for library developers who
|
||||
implement higher-level APIs on top of it.
|
||||
|
||||
The `controller` passed to each handler is a `DispatchController` with the
|
||||
following shape:
|
||||
|
||||
* `aborted` {boolean} Whether the request has been aborted.
|
||||
* `paused` {boolean} Whether the request is paused.
|
||||
* `reason` {Error|null} The reason the request was aborted, if any.
|
||||
* `rawHeaders` {Buffer[]|string[]|Object|null} The raw response headers,
|
||||
preserving duplicates and casing.
|
||||
* `rawTrailers` {Buffer[]|string[]|Object|null} The raw response trailers.
|
||||
* `abort(reason)` Aborts the request with the given `reason`.
|
||||
* `pause()` Pauses the response stream.
|
||||
* `resume()` Resumes the response stream.
|
||||
|
||||
Call `controller.pause()` and `controller.resume()` to apply backpressure rather
|
||||
than returning `false` from a handler.
|
||||
|
||||
```mjs
|
||||
import { createServer } from 'node:http'
|
||||
import { Client } from 'undici'
|
||||
import { once } from 'node:events'
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
response.end('Hello, World!')
|
||||
}).listen()
|
||||
|
||||
await once(server, 'listening')
|
||||
|
||||
const client = new Client(`http://localhost:${server.address().port}`)
|
||||
|
||||
const data = []
|
||||
|
||||
client.dispatch({
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
headers: { 'x-foo': 'bar' }
|
||||
}, {
|
||||
onRequestStart: () => {
|
||||
console.log('Connected!')
|
||||
},
|
||||
onResponseError: (_controller, error) => {
|
||||
console.error(error)
|
||||
},
|
||||
onResponseStart: (_controller, statusCode, headers) => {
|
||||
console.log(`statusCode: ${statusCode} | headers: ${JSON.stringify(headers)}`)
|
||||
},
|
||||
onResponseData: (_controller, chunk) => {
|
||||
data.push(chunk)
|
||||
},
|
||||
onResponseEnd: (_controller, trailers) => {
|
||||
console.log(`trailers: ${JSON.stringify(trailers)}`)
|
||||
console.log(`Data: ${Buffer.concat(data).toString('utf8')}`)
|
||||
client.close()
|
||||
server.close()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### `dispatcher.pipeline(options, handler)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `options` {Object} Accepts every field of [`dispatcher.request()`][]'s
|
||||
`options`, plus:
|
||||
* `objectMode` {boolean} Set to `true` when `handler` returns an object stream.
|
||||
**Default:** `false`.
|
||||
* `handler` {Function} Returns a `Readable` from which the result is read.
|
||||
* `data` {Object}
|
||||
* `statusCode` {number}
|
||||
* `headers` {Object} The response headers.
|
||||
* `opaque` {any} The `opaque` value passed in `options`.
|
||||
* `body` {Readable} The response body.
|
||||
* `context` {Object}
|
||||
* Returns: {Readable}
|
||||
* Returns: {Duplex} A duplex stream that writes to the request and reads from the
|
||||
response.
|
||||
|
||||
For easy use with [`stream.pipeline()`][]. The `handler` should return a
|
||||
`Readable` from which the result is read. Usually it returns the `body` directly,
|
||||
unless a transformation is needed based on, for example, `headers` or
|
||||
`statusCode`. The `handler` should validate the response and save any required
|
||||
state; if there is an error, it should be thrown.
|
||||
|
||||
```mjs
|
||||
import { Readable, Writable, PassThrough, pipeline } from 'node:stream'
|
||||
import { createServer } from 'node:http'
|
||||
import { Client } from 'undici'
|
||||
import { once } from 'node:events'
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
request.pipe(response)
|
||||
}).listen()
|
||||
|
||||
await once(server, 'listening')
|
||||
|
||||
const client = new Client(`http://localhost:${server.address().port}`)
|
||||
|
||||
let res = ''
|
||||
|
||||
pipeline(
|
||||
new Readable({
|
||||
read () {
|
||||
this.push(Buffer.from('undici'))
|
||||
this.push(null)
|
||||
}
|
||||
}),
|
||||
client.pipeline({ path: '/', method: 'GET' }, ({ statusCode, headers, body }) => {
|
||||
console.log(`response received ${statusCode}`)
|
||||
console.log('headers', headers)
|
||||
return pipeline(body, new PassThrough(), () => {})
|
||||
}),
|
||||
new Writable({
|
||||
write (chunk, _, callback) {
|
||||
res += chunk.toString()
|
||||
callback()
|
||||
},
|
||||
final (callback) {
|
||||
console.log(`Response pipelined to writable: ${res}`)
|
||||
callback()
|
||||
}
|
||||
}),
|
||||
error => {
|
||||
if (error) {
|
||||
console.error(error)
|
||||
}
|
||||
client.close()
|
||||
server.close()
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### `dispatcher.request(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `options` {Object} Accepts every field of [`dispatcher.dispatch()`][]'s
|
||||
`options`, plus:
|
||||
* `opaque` {any} A value passed through to the returned response data.
|
||||
**Default:** `null`.
|
||||
* `signal` {AbortSignal|EventEmitter|null} A signal used to abort the request.
|
||||
**Default:** `null`.
|
||||
* `onInfo` {Function|null} Invoked for each informational (1xx) response
|
||||
received. **Default:** `null`.
|
||||
* `info` {Object}
|
||||
* `statusCode` {number}
|
||||
* `headers` {Object}
|
||||
* `responseHeaders` {string|null} Set to `'raw'` to return the response headers
|
||||
as a raw array. **Default:** `null`.
|
||||
* `highWaterMark` {number} The high water mark of the response body stream.
|
||||
**Default:** `65536` (64 KiB).
|
||||
* `callback` {Function} (optional) Invoked when the response is received.
|
||||
* `error` {Error|null}
|
||||
* `data` {Object}
|
||||
* `statusCode` {number}
|
||||
* `statusText` {string} The status message, e.g. `'OK'` or `'Not Found'`.
|
||||
* `headers` {Object} The response headers. All keys are lower-cased.
|
||||
* `body` {Readable} The response body, which also implements the
|
||||
[body mixin][] from the Fetch Standard.
|
||||
* `trailers` {Object} Starts out empty and is mutated to contain the trailers
|
||||
after `body` emits `'end'`.
|
||||
* `opaque` {any} The `opaque` value passed in `options`.
|
||||
* `context` {Object}
|
||||
* Returns: {Promise} A `Promise` is returned only when `callback` is omitted. It
|
||||
resolves with the `data` object described above.
|
||||
|
||||
Performs an HTTP request. `options.method` must not be `'CONNECT'`.
|
||||
|
||||
Non-idempotent requests are not pipelined, to avoid indirect failures. Idempotent
|
||||
requests are automatically retried if they fail due to an indirect failure from
|
||||
the request at the head of the pipeline; this does not apply to idempotent
|
||||
requests with a stream request body. All response bodies must always be fully
|
||||
consumed or destroyed.
|
||||
|
||||
The `body` exposes the following [body mixin][] methods and properties:
|
||||
[`.arrayBuffer()`][], [`.blob()`][], [`.bytes()`][], [`.json()`][],
|
||||
[`.text()`][], `body`, and `bodyUsed`. A body cannot be consumed twice; for
|
||||
example, calling `text()` after `json()` throws a `TypeError`. The body also
|
||||
provides `dump({ limit })`, which discards up to `limit` bytes (default
|
||||
`131072`) without destroying the socket.
|
||||
|
||||
> [!WARNING]
|
||||
> The `arrayBuffer()`, `blob()`, `bytes()`, `json()`, and `text()` methods buffer
|
||||
> the entire body in memory before returning. Where applicable, they also decode
|
||||
> or parse the payload and retain that representation in memory. Calling these
|
||||
> methods therefore means trusting that the body is small enough to fit in the
|
||||
> available memory. Do not use them for bodies received from untrusted or
|
||||
> user-controlled sources. Instead, process `body` as a `Readable` stream and
|
||||
> enforce an application-specific size limit.
|
||||
|
||||
The body is always a `Readable`, even when empty. Deserializing an empty body
|
||||
with `json()` throws. To guard against this, verify the status code is not `204`
|
||||
and the `content-type` header starts with `application/json` before calling
|
||||
`json()`.
|
||||
|
||||
```mjs
|
||||
import { createServer } from 'node:http'
|
||||
import { Client } from 'undici'
|
||||
import { once } from 'node:events'
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
response.end('Hello, World!')
|
||||
}).listen()
|
||||
|
||||
await once(server, 'listening')
|
||||
|
||||
const client = new Client(`http://localhost:${server.address().port}`)
|
||||
|
||||
try {
|
||||
const { body, headers, statusCode, statusText, trailers } = await client.request({
|
||||
path: '/',
|
||||
method: 'GET'
|
||||
})
|
||||
console.log(`response received ${statusCode}`)
|
||||
console.log('headers', headers)
|
||||
body.setEncoding('utf8')
|
||||
body.on('data', console.log)
|
||||
body.on('error', console.error)
|
||||
body.on('end', () => {
|
||||
console.log('trailers', trailers)
|
||||
})
|
||||
|
||||
client.close()
|
||||
server.close()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
```
|
||||
|
||||
A request can be aborted with an `AbortSignal`, with any `EventEmitter` that
|
||||
emits an `'abort'` event, or by destroying the response body:
|
||||
|
||||
```mjs
|
||||
import { Client } from 'undici'
|
||||
|
||||
const client = new Client('http://localhost:3000')
|
||||
const abortController = new AbortController()
|
||||
|
||||
try {
|
||||
client.request({ path: '/', method: 'GET', signal: abortController.signal })
|
||||
} catch (error) {
|
||||
console.error(error) // RequestAbortedError
|
||||
}
|
||||
|
||||
abortController.abort()
|
||||
```
|
||||
|
||||
When the response body is conditionally read, always fully consume it otherwise:
|
||||
|
||||
```mjs
|
||||
const { body, statusCode } = await client.request({ path: '/', method: 'GET' })
|
||||
|
||||
if (statusCode === 200) {
|
||||
return await body.arrayBuffer()
|
||||
}
|
||||
|
||||
await body.dump()
|
||||
|
||||
return null
|
||||
```
|
||||
|
||||
### `dispatcher.stream(options, factory[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `options` {Object} Accepts the same fields as [`dispatcher.request()`][]'s
|
||||
`options`.
|
||||
* `factory` {Function} Returns the `Writable` to which the response is written.
|
||||
* `data` {Object}
|
||||
* `statusCode` {number}
|
||||
* `headers` {Object} The response headers.
|
||||
* `opaque` {any} The `opaque` value passed in `options`.
|
||||
* `context` {Object}
|
||||
* Returns: {Writable}
|
||||
* `callback` {Function} (optional) Invoked when the request has completed.
|
||||
* `error` {Error|null}
|
||||
* `data` {Object}
|
||||
* `opaque` {any} The `opaque` value passed in `options`.
|
||||
* `trailers` {Object}
|
||||
* Returns: {Promise} A `Promise` is returned only when `callback` is omitted. It
|
||||
resolves with the `data` object described above.
|
||||
|
||||
A faster version of [`dispatcher.request()`][]. The `factory` returns a
|
||||
[`Writable`][] to which the response is written, which avoids creating an
|
||||
intermediate [`Readable`][] when the caller intends to pipe the response body
|
||||
directly to a `Writable`.
|
||||
|
||||
Use `options.opaque` to avoid creating a closure for the `factory` method. This
|
||||
pattern works well with Node.js web frameworks such as [Fastify][].
|
||||
|
||||
```mjs
|
||||
import { createServer } from 'node:http'
|
||||
import { Client } from 'undici'
|
||||
import { once } from 'node:events'
|
||||
import { Writable } from 'node:stream'
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
response.end('Hello, World!')
|
||||
}).listen()
|
||||
|
||||
await once(server, 'listening')
|
||||
|
||||
const client = new Client(`http://localhost:${server.address().port}`)
|
||||
|
||||
const bufs = []
|
||||
|
||||
try {
|
||||
await client.stream({
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
opaque: { bufs }
|
||||
}, ({ statusCode, headers, opaque: { bufs } }) => {
|
||||
console.log(`response received ${statusCode}`)
|
||||
console.log('headers', headers)
|
||||
return new Writable({
|
||||
write (chunk, encoding, callback) {
|
||||
bufs.push(chunk)
|
||||
callback()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
console.log(Buffer.concat(bufs).toString('utf-8'))
|
||||
|
||||
client.close()
|
||||
server.close()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
```
|
||||
|
||||
### `dispatcher.upgrade(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `options` {Object}
|
||||
* `path` {string} The request path.
|
||||
* `method` {string} The HTTP method. **Default:** `'GET'`.
|
||||
* `headers` {UndiciHeaders} Request headers. **Default:** `null`.
|
||||
* `protocol` {string} A comma-separated list of protocols, in descending
|
||||
preference order. **Default:** `'Websocket'`.
|
||||
* `signal` {AbortSignal|EventEmitter|null} A signal used to abort the request.
|
||||
**Default:** `null`.
|
||||
* `responseHeaders` {string|null} Set to `'raw'` to return the response headers
|
||||
as a raw array. **Default:** `null`.
|
||||
* `callback` {Function} (optional) Invoked when the upgrade completes.
|
||||
* `error` {Error|null}
|
||||
* `data` {Object}
|
||||
* `headers` {Object} The response headers.
|
||||
* `socket` {Duplex} The upgraded socket.
|
||||
* `opaque` {any}
|
||||
* Returns: {Promise} A `Promise` is returned only when `callback` is omitted. It
|
||||
resolves with the `data` object described above.
|
||||
|
||||
Upgrades to a different protocol. See
|
||||
[MDN: Protocol upgrade mechanism][] for more details.
|
||||
|
||||
```mjs
|
||||
import { createServer } from 'node:http'
|
||||
import { Client } from 'undici'
|
||||
import { once } from 'node:events'
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
response.statusCode = 101
|
||||
response.setHeader('connection', 'upgrade')
|
||||
response.setHeader('upgrade', request.headers.upgrade)
|
||||
response.end()
|
||||
}).listen()
|
||||
|
||||
await once(server, 'listening')
|
||||
|
||||
const client = new Client(`http://localhost:${server.address().port}`)
|
||||
|
||||
try {
|
||||
const { headers, socket } = await client.upgrade({ path: '/' })
|
||||
socket.on('end', () => {
|
||||
console.log(`upgrade: ${headers.upgrade}`) // upgrade: Websocket
|
||||
client.close()
|
||||
server.close()
|
||||
})
|
||||
socket.end()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
client.close()
|
||||
server.close()
|
||||
}
|
||||
```
|
||||
|
||||
### `dispatcher.compose(interceptors[, interceptor])`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.9.0
|
||||
-->
|
||||
|
||||
* `interceptors` {Array|Function} An array of interceptor functions, or the first
|
||||
of several interceptors passed as separate arguments.
|
||||
* `...interceptor` {Function} Additional interceptors when passed as separate
|
||||
arguments.
|
||||
* Returns: {Dispatcher} A new dispatcher that applies the interceptors over the
|
||||
current dispatcher's [`dispatch()`][] method.
|
||||
|
||||
Composes a new dispatcher from the current dispatcher and the given
|
||||
interceptors. Each interceptor is a function that takes a `dispatch` method and
|
||||
returns a `dispatch`-like function with the same signature
|
||||
(`(options, handler)`).
|
||||
|
||||
The order of the interceptors matters: the last interceptor in the chain is the
|
||||
first to be called. Forking the chain of interceptors can lead to unexpected
|
||||
results.
|
||||
|
||||
```text
|
||||
compose([interceptor1, interceptor2, interceptor3])
|
||||
|
||||
Request flow:
|
||||
Request -> interceptor3 -> interceptor2 -> interceptor1 -> dispatcher.dispatch
|
||||
(called first) (called second) (called last)
|
||||
```
|
||||
|
||||
```mjs
|
||||
import { Client, RedirectHandler } from 'undici'
|
||||
|
||||
const redirectInterceptor = dispatch => {
|
||||
return (opts, handler) => {
|
||||
const { maxRedirections } = opts
|
||||
|
||||
if (!maxRedirections) {
|
||||
return dispatch(opts, handler)
|
||||
}
|
||||
|
||||
const redirectHandler = new RedirectHandler(dispatch, maxRedirections, opts, handler)
|
||||
opts = { ...opts, maxRedirections: 0 } // Stop the sub-dispatcher from also redirecting.
|
||||
return dispatch(opts, redirectHandler)
|
||||
}
|
||||
}
|
||||
|
||||
const client = new Client('http://localhost:3000').compose(redirectInterceptor)
|
||||
|
||||
await client.request({ path: '/', method: 'GET' })
|
||||
```
|
||||
|
||||
Interceptors can be chained by calling `compose()` repeatedly:
|
||||
|
||||
```mjs
|
||||
import { Client } from 'undici'
|
||||
|
||||
const client = new Client('http://localhost:3000')
|
||||
.compose(redirectInterceptor)
|
||||
.compose(retryInterceptor)
|
||||
|
||||
await client.request({ path: '/', method: 'GET' })
|
||||
```
|
||||
|
||||
For the full list of built-in interceptors provided by undici, see [Interceptors](Interceptors.md).
|
||||
For an example of a custom interceptor that wraps handler callbacks, see
|
||||
[Custom interceptors](Interceptors.md#custom-interceptors).
|
||||
|
||||
### Event: `'connect'`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `origin` {URL}
|
||||
* `targets` {Array<Dispatcher>}
|
||||
|
||||
Emitted when the dispatcher has connected to the origin.
|
||||
|
||||
### Event: `'disconnect'`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `origin` {URL}
|
||||
* `targets` {Array<Dispatcher>}
|
||||
* `error` {Error}
|
||||
|
||||
Emitted when the dispatcher has been disconnected from the origin.
|
||||
|
||||
For HTTP/2, this event is also emitted when the dispatcher receives a
|
||||
[GOAWAY frame][], with an error whose message is `HTTP/2: "GOAWAY" frame received`
|
||||
and whose code is `UND_ERR_INFO`. Because of the binary-framing nature of the
|
||||
protocol, a request may hang if a frame is received between the `HEADER` and
|
||||
`DATA` frames. It is recommended to handle this event and close the dispatcher to
|
||||
create a new HTTP/2 session.
|
||||
|
||||
### Event: `'connectionError'`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `origin` {URL}
|
||||
* `targets` {Array<Dispatcher>}
|
||||
* `error` {Error}
|
||||
|
||||
Emitted when the dispatcher fails to connect to the origin.
|
||||
|
||||
### Event: `'drain'`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `origin` {URL}
|
||||
|
||||
Emitted when the dispatcher is no longer busy and further [`dispatch()`][] calls
|
||||
can make progress.
|
||||
|
||||
## Pre-built interceptors
|
||||
|
||||
For the full reference of built-in interceptors (`dump`, `retry`, `redirect`,
|
||||
`decompress`, `responseError`, `dns`, `cache`, `deduplicate`) and their options,
|
||||
see [Interceptors](Interceptors.md).
|
||||
|
||||
[Fastify]: https://fastify.dev
|
||||
[GOAWAY frame]: https://webconcepts.info/concepts/http2-frame-type/0x7
|
||||
[HTTP `CONNECT`]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/CONNECT
|
||||
[MDN: Protocol upgrade mechanism]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism
|
||||
[`.arrayBuffer()`]: https://fetch.spec.whatwg.org/#dom-body-arraybuffer
|
||||
[`.blob()`]: https://fetch.spec.whatwg.org/#dom-body-blob
|
||||
[`.bytes()`]: https://fetch.spec.whatwg.org/#dom-body-bytes
|
||||
[`.json()`]: https://fetch.spec.whatwg.org/#dom-body-json
|
||||
[`.text()`]: https://fetch.spec.whatwg.org/#dom-body-text
|
||||
[`'drain'`]: #event-drain
|
||||
[`Agent`]: Agent.md#class-agent
|
||||
[`BalancedPool`]: BalancedPool.md#class-balancedpool
|
||||
[`Client`]: Client.md#class-client
|
||||
[`EventEmitter`]: https://nodejs.org/api/events.html#class-eventemitter
|
||||
[`Pool`]: Pool.md#class-pool
|
||||
[`Readable`]: https://nodejs.org/api/stream.html#class-streamreadable
|
||||
[`Writable`]: https://nodejs.org/api/stream.html#class-streamwritable
|
||||
[`close()`]: #dispatcherclosecallback
|
||||
[`connect()`]: #dispatcherconnectoptions-callback
|
||||
[`destroy()`]: #dispatcherdestroyerror-callback
|
||||
[`dispatch()`]: #dispatcherdispatchoptions-handler
|
||||
[`dispatcher.compose()`]: #dispatchercomposeinterceptors-interceptor
|
||||
[`dispatcher.dispatch()`]: #dispatcherdispatchoptions-handler
|
||||
[`dispatcher.request()`]: #dispatcherrequestoptions-callback
|
||||
[`pipeline()`]: #dispatcherpipelineoptions-handler
|
||||
[`request()`]: #dispatcherrequestoptions-callback
|
||||
[`stream()`]: #dispatcherstreamoptions-factory-callback
|
||||
[`stream.pipeline()`]: https://nodejs.org/api/stream.html#streampipelinesource-transforms-destination-options
|
||||
[`upgrade()`]: #dispatcherupgradeoptions-callback
|
||||
[body mixin]: https://fetch.spec.whatwg.org/#body-mixin
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
# EnvHttpProxyAgent
|
||||
|
||||
<!--introduced_in=v6.14.0-->
|
||||
|
||||
<!--type=module-->
|
||||
|
||||
<!-- source_link=lib/dispatcher/env-http-proxy-agent.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
`EnvHttpProxyAgent` is a [`Dispatcher`][] that reads its proxy configuration from
|
||||
the `http_proxy`, `https_proxy`, and `no_proxy` environment variables and routes
|
||||
requests through the appropriate proxy automatically. Import it from `undici`:
|
||||
|
||||
```mjs
|
||||
import { EnvHttpProxyAgent } from 'undici'
|
||||
```
|
||||
|
||||
When `http_proxy` and `https_proxy` are both set, `http_proxy` is used for HTTP
|
||||
requests and `https_proxy` is used for HTTPS requests. If only `http_proxy` is
|
||||
set, it is used for both HTTP and HTTPS requests. If only `https_proxy` is set,
|
||||
it is used only for HTTPS requests.
|
||||
|
||||
`no_proxy` is a comma- or space-separated list of hosts that must not be
|
||||
proxied. Each entry may include a leading dot or `*.` wildcard (for example
|
||||
`.example.com`) to match subdomains, and an optional `:port` suffix to restrict
|
||||
the match to a specific port. A request bypasses the proxy when its host equals
|
||||
an entry or is a subdomain of one. A trailing dot is ignored on both sides, so
|
||||
`example.com.` and `example.com` match each other. Setting `no_proxy` to `*`
|
||||
bypasses the proxy for every request.
|
||||
|
||||
The uppercase variants `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` are also
|
||||
honored. When both the lowercase and uppercase forms of a variable are set, the
|
||||
lowercase form takes precedence and the uppercase form is ignored. The `no_proxy`
|
||||
value is re-read from the environment on demand, so changes to it after the agent
|
||||
is created take effect on subsequent requests; this does not apply when `noProxy`
|
||||
is passed as an option.
|
||||
|
||||
## Class: `EnvHttpProxyAgent`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.14.0
|
||||
-->
|
||||
|
||||
* Extends: {Dispatcher}
|
||||
|
||||
### `new EnvHttpProxyAgent([options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.14.0
|
||||
-->
|
||||
|
||||
* `options` {EnvHttpProxyAgentOptions} Extends the [`ProxyAgent`][] options
|
||||
(except `uri`). (optional)
|
||||
* `httpProxy` {string} When set, overrides the `http_proxy` and `HTTP_PROXY`
|
||||
environment variables. (optional)
|
||||
* `httpsProxy` {string} When set, overrides the `https_proxy` and
|
||||
`HTTPS_PROXY` environment variables. (optional)
|
||||
* `noProxy` {string} When set, overrides the `no_proxy` and `NO_PROXY`
|
||||
environment variables. (optional)
|
||||
* Returns: {EnvHttpProxyAgent}
|
||||
|
||||
Creates a new `EnvHttpProxyAgent`. Any options other than `httpProxy`,
|
||||
`httpsProxy`, and `noProxy` are forwarded to the underlying [`Agent`][] and
|
||||
[`ProxyAgent`][] instances. Constructing the agent only configures it; it does
|
||||
not affect any request until it is registered as a dispatcher, either globally
|
||||
with `setGlobalDispatcher()` or per-request through the `dispatcher` option.
|
||||
|
||||
```mjs
|
||||
import { EnvHttpProxyAgent } from 'undici'
|
||||
|
||||
const envHttpProxyAgent = new EnvHttpProxyAgent()
|
||||
// or, overriding the environment variables explicitly:
|
||||
const envHttpProxyAgentWithOpts = new EnvHttpProxyAgent({
|
||||
httpProxy: 'my.proxy.server:8080',
|
||||
httpsProxy: 'my.proxy.server:8443',
|
||||
noProxy: 'localhost'
|
||||
})
|
||||
```
|
||||
|
||||
#### Example - Proxied `fetch()` with the global dispatcher
|
||||
|
||||
```mjs
|
||||
import { setGlobalDispatcher, fetch, EnvHttpProxyAgent } from 'undici'
|
||||
|
||||
const envHttpProxyAgent = new EnvHttpProxyAgent()
|
||||
setGlobalDispatcher(envHttpProxyAgent)
|
||||
|
||||
const response = await fetch('http://localhost:3000/foo')
|
||||
|
||||
console.log('response received', response.status) // response received 200
|
||||
|
||||
const data = await response.json() // data { foo: 'bar' }
|
||||
```
|
||||
|
||||
#### Example - Proxied `request()` with the global dispatcher
|
||||
|
||||
```mjs
|
||||
import { setGlobalDispatcher, request, EnvHttpProxyAgent } from 'undici'
|
||||
|
||||
const envHttpProxyAgent = new EnvHttpProxyAgent()
|
||||
setGlobalDispatcher(envHttpProxyAgent)
|
||||
|
||||
const { statusCode, body } = await request('http://localhost:3000/foo')
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
|
||||
for await (const data of body) {
|
||||
console.log('data', data.toString('utf8')) // data foo
|
||||
}
|
||||
```
|
||||
|
||||
#### Example - Proxied `request()` with a local dispatcher
|
||||
|
||||
```mjs
|
||||
import { EnvHttpProxyAgent, request } from 'undici'
|
||||
|
||||
const envHttpProxyAgent = new EnvHttpProxyAgent()
|
||||
|
||||
const { statusCode, body } = await request('http://localhost:3000/foo', {
|
||||
dispatcher: envHttpProxyAgent
|
||||
})
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
|
||||
for await (const data of body) {
|
||||
console.log('data', data.toString('utf8')) // data foo
|
||||
}
|
||||
```
|
||||
|
||||
#### Example - Proxied `fetch()` with a local dispatcher
|
||||
|
||||
```mjs
|
||||
import { EnvHttpProxyAgent, fetch } from 'undici'
|
||||
|
||||
const envHttpProxyAgent = new EnvHttpProxyAgent()
|
||||
|
||||
const response = await fetch('http://localhost:3000/foo', {
|
||||
dispatcher: envHttpProxyAgent
|
||||
})
|
||||
|
||||
console.log('response received', response.status) // response received 200
|
||||
|
||||
const data = await response.json() // data { foo: 'bar' }
|
||||
```
|
||||
|
||||
### `envHttpProxyAgent.dispatch(options, handler)`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.14.0
|
||||
-->
|
||||
|
||||
* `options` {AgentDispatchOptions}
|
||||
* `origin` {string|URL}
|
||||
* `handler` {DispatchHandler}
|
||||
* Returns: {boolean}
|
||||
|
||||
Dispatches a request through the proxy selected for `options.origin`. The origin's
|
||||
protocol, host, and port are compared against the `no_proxy` configuration: when
|
||||
the host should be proxied, the request is routed to the HTTPS proxy agent for
|
||||
`https:` origins or the HTTP proxy agent otherwise; when the host is excluded by
|
||||
`no_proxy`, the request is sent directly through a non-proxying [`Agent`][].
|
||||
|
||||
This method is not normally called directly. Use the higher-level
|
||||
[`Dispatcher`][] methods such as [`dispatcher.request()`][] or the top-level
|
||||
`request()` and `fetch()` helpers instead.
|
||||
|
||||
[`Agent`]: Agent.md#class-agent
|
||||
[`Dispatcher`]: Dispatcher.md#class-dispatcher
|
||||
[`ProxyAgent`]: ProxyAgent.md#class-proxyagent
|
||||
[`dispatcher.request()`]: Dispatcher.md#dispatcherrequestoptions-callback
|
||||
+549
@@ -0,0 +1,549 @@
|
||||
# Errors
|
||||
|
||||
<!--introduced_in=v1.0.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/core/errors.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
undici raises typed error objects so that failures can be distinguished
|
||||
programmatically. Every error class is exposed through the `errors` namespace of
|
||||
the package:
|
||||
|
||||
```js
|
||||
import { errors } from 'undici'
|
||||
|
||||
if (err instanceof errors.ConnectTimeoutError) {
|
||||
// handle connect timeout
|
||||
}
|
||||
```
|
||||
|
||||
All errors, except [`HTTPParserError`][], extend [`UndiciError`][]. Each error
|
||||
carries a stable `code` string (for example `UND_ERR_CONNECT_TIMEOUT`) and a
|
||||
`name`.
|
||||
|
||||
Because the bundled (global) dispatcher may come from a different undici version
|
||||
than the one you import directly, prefer matching on `error.code` rather than
|
||||
`instanceof errors.UndiciError`:
|
||||
|
||||
```js
|
||||
if (err.code === 'UND_ERR_CONNECT_TIMEOUT') {
|
||||
// handle connect timeout
|
||||
}
|
||||
```
|
||||
|
||||
## Class: `UndiciError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made `UndiciError` and its subclasses reliable with `instanceof`
|
||||
across realms and undici versions.
|
||||
-->
|
||||
|
||||
* Extends: {Error}
|
||||
|
||||
The base class for all undici errors. It is reliable with `instanceof` even when
|
||||
the error originates from a different undici version, because the check is based
|
||||
on a well-known symbol rather than the prototype chain.
|
||||
|
||||
* `name` {string} Always `'UndiciError'`.
|
||||
* `code` {string} Always `'UND_ERR'`.
|
||||
|
||||
## Class: `ConnectTimeoutError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
The socket was destroyed because establishing the connection exceeded the
|
||||
`connectTimeout` option.
|
||||
|
||||
* `name` {string} Always `'ConnectTimeoutError'`.
|
||||
* `code` {string} Always `'UND_ERR_CONNECT_TIMEOUT'`.
|
||||
|
||||
When `autoSelectFamily` is enabled and every attempted address times out, Node.js
|
||||
raises an `AggregateError`. undici normalizes these multi-address timeouts into a
|
||||
`ConnectTimeoutError` so that the error shape is the same regardless of whether
|
||||
Node.js's per-address timer or undici's `connectTimeout` wins the race. The
|
||||
original `AggregateError` is preserved on `error.cause`.
|
||||
|
||||
## Class: `HeadersTimeoutError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
The socket was destroyed because receiving the response headers exceeded the
|
||||
`headersTimeout` option.
|
||||
|
||||
* `name` {string} Always `'HeadersTimeoutError'`.
|
||||
* `code` {string} Always `'UND_ERR_HEADERS_TIMEOUT'`.
|
||||
|
||||
## Class: `HeadersOverflowError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
The socket was destroyed because the response headers exceeded the maximum
|
||||
allowed size.
|
||||
|
||||
* `name` {string} Always `'HeadersOverflowError'`.
|
||||
* `code` {string} Always `'UND_ERR_HEADERS_OVERFLOW'`.
|
||||
|
||||
## Class: `BodyTimeoutError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
The socket was destroyed because reading the response body exceeded the
|
||||
`bodyTimeout` option.
|
||||
|
||||
* `name` {string} Always `'BodyTimeoutError'`.
|
||||
* `code` {string} Always `'UND_ERR_BODY_TIMEOUT'`.
|
||||
|
||||
## Class: `InvalidArgumentError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
An invalid argument was passed to an undici API.
|
||||
|
||||
* `name` {string} Always `'InvalidArgumentError'`.
|
||||
* `code` {string} Always `'UND_ERR_INVALID_ARG'`.
|
||||
|
||||
## Class: `InvalidReturnValueError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
A user-supplied callback or interceptor returned an invalid value.
|
||||
|
||||
* `name` {string} Always `'InvalidReturnValueError'`.
|
||||
* `code` {string} Always `'UND_ERR_INVALID_RETURN_VALUE'`.
|
||||
|
||||
## Class: `AbortError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
The operation was aborted. This is the base class of [`RequestAbortedError`][].
|
||||
|
||||
* `name` {string} Always `'AbortError'`.
|
||||
* `code` {string} Always `'UND_ERR_ABORT'`.
|
||||
|
||||
## Class: `RequestAbortedError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {AbortError}
|
||||
|
||||
The request was aborted by the user, typically through an `AbortSignal`.
|
||||
|
||||
* `name` {string} Always `'AbortError'`.
|
||||
* `code` {string} Always `'UND_ERR_ABORTED'`.
|
||||
|
||||
## Class: `InformationalError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
An expected error carrying a reason, used internally to surface a cause for a
|
||||
failed or retried request (for example as the `cause` of another error).
|
||||
|
||||
* `name` {string} Always `'InformationalError'`.
|
||||
* `code` {string} Always `'UND_ERR_INFO'`.
|
||||
|
||||
## Class: `RequestContentLengthMismatchError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
The length of the request body did not match the `content-length` header.
|
||||
|
||||
* `name` {string} Always `'RequestContentLengthMismatchError'`.
|
||||
* `code` {string} Always `'UND_ERR_REQ_CONTENT_LENGTH_MISMATCH'`.
|
||||
|
||||
## Class: `ResponseContentLengthMismatchError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
The length of the response body did not match the `content-length` header.
|
||||
|
||||
* `name` {string} Always `'ResponseContentLengthMismatchError'`.
|
||||
* `code` {string} Always `'UND_ERR_RES_CONTENT_LENGTH_MISMATCH'`.
|
||||
|
||||
## Class: `ClientDestroyedError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
An operation was attempted on a client that has already been destroyed.
|
||||
|
||||
* `name` {string} Always `'ClientDestroyedError'`.
|
||||
* `code` {string} Always `'UND_ERR_DESTROYED'`.
|
||||
|
||||
## Class: `ClientClosedError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
An operation was attempted on a client that has already been closed.
|
||||
|
||||
* `name` {string} Always `'ClientClosedError'`.
|
||||
* `code` {string} Always `'UND_ERR_CLOSED'`.
|
||||
|
||||
## Class: `SocketError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
An error occurred on the underlying socket.
|
||||
|
||||
* `name` {string} Always `'SocketError'`.
|
||||
* `code` {string} Always `'UND_ERR_SOCKET'`.
|
||||
* `socket` {SocketInfo|null} Metadata about the socket at the time of the error,
|
||||
or `null` when no socket information is available.
|
||||
|
||||
The `socket` property, when present, holds the following fields:
|
||||
|
||||
```ts
|
||||
interface SocketInfo {
|
||||
localAddress?: string
|
||||
localPort?: number
|
||||
remoteAddress?: string
|
||||
remotePort?: number
|
||||
remoteFamily?: string
|
||||
timeout?: number
|
||||
bytesWritten?: number
|
||||
bytesRead?: number
|
||||
}
|
||||
```
|
||||
|
||||
## Class: `NotSupportedError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
Unsupported functionality was requested.
|
||||
|
||||
* `name` {string} Always `'NotSupportedError'`.
|
||||
* `code` {string} Always `'UND_ERR_NOT_SUPPORTED'`.
|
||||
|
||||
## Class: `BalancedPoolMissingUpstreamError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
No upstream has been added to the [`BalancedPool`][].
|
||||
|
||||
* `name` {string} Always `'MissingUpstreamError'`.
|
||||
* `code` {string} Always `'UND_ERR_BPL_MISSING_UPSTREAM'`.
|
||||
|
||||
## Class: `HTTPParserError`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Extends: {Error}
|
||||
|
||||
An error occurred while parsing the HTTP response. Unlike the other error
|
||||
classes, `HTTPParserError` extends {Error} directly rather than
|
||||
[`UndiciError`][].
|
||||
|
||||
* `name` {string} Always `'HTTPParserError'`.
|
||||
* `code` {string} An `HPE_`-prefixed parser error code, or `undefined` when no
|
||||
code was provided.
|
||||
* `data` {string} The raw data associated with the parser failure, or
|
||||
`undefined` when none was provided.
|
||||
|
||||
## Class: `ResponseExceededMaxSizeError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
The response body exceeded the maximum allowed size.
|
||||
|
||||
* `name` {string} Always `'ResponseExceededMaxSizeError'`.
|
||||
* `code` {string} Always `'UND_ERR_RES_EXCEEDED_MAX_SIZE'`.
|
||||
|
||||
## Class: `RequestRetryError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
A request failed and could not be retried, for example because the body is
|
||||
stateful (a stream or `AsyncIterable`) and cannot be replayed.
|
||||
|
||||
* `name` {string} Always `'RequestRetryError'`.
|
||||
* `code` {string} Always `'UND_ERR_REQ_RETRY'`.
|
||||
* `statusCode` {number} The HTTP status code of the failed response.
|
||||
* `data` {Object} Retry metadata.
|
||||
* `count` {number} The number of retry attempts that were made.
|
||||
* `headers` {Record<string, string|string[]>} The response headers.
|
||||
|
||||
### `new RequestRetryError(message, code, options)`
|
||||
|
||||
* `message` {string} The error message.
|
||||
* `code` {number} The HTTP status code of the failed response.
|
||||
* `options` {Object}
|
||||
* `headers` {Object|string[]|null} The response headers. (optional)
|
||||
* `data` {Object|string|null} Retry metadata. (optional)
|
||||
|
||||
## Class: `ResponseError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
The response returned an error status code. This is raised, for example, when the
|
||||
`throwOnError` option is enabled.
|
||||
|
||||
* `name` {string} Always `'ResponseError'`.
|
||||
* `code` {string} Always `'UND_ERR_RESPONSE'`.
|
||||
* `statusCode` {number} The HTTP status code of the response.
|
||||
* `body` {Object|string|null} The response body.
|
||||
* `headers` {Object|string[]|null} The response headers.
|
||||
|
||||
### `new ResponseError(message, code, options)`
|
||||
|
||||
* `message` {string} The error message.
|
||||
* `code` {number} The HTTP status code of the response.
|
||||
* `options` {Object}
|
||||
* `headers` {Object|string[]|null} The response headers. (optional)
|
||||
* `body` {Object|string|null} The response body. (optional)
|
||||
|
||||
## Class: `ProxyConnectionError`
|
||||
|
||||
<!-- YAML
|
||||
added: v8.10.1
|
||||
changes:
|
||||
- version: v8.10.1
|
||||
pr-url: https://github.com/nodejs/undici/pull/5707
|
||||
description: Added to fail the request instead of retrying forever when the proxy connection is torn down.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
A connection to the proxy failed in a way that cannot be recovered on the
|
||||
same connection, so the request fails instead of being retried.
|
||||
|
||||
* `name` {string} Always `'ProxyConnectionError'`.
|
||||
* `code` {string} Always `'UND_ERR_PRX_CONN'`.
|
||||
* `cause` {Error} The underlying error that caused the proxy connection to fail.
|
||||
|
||||
### `new ProxyConnectionError(cause[, message[, options]])`
|
||||
|
||||
* `cause` {Error} The underlying error. (optional)
|
||||
* `message` {string} The error message. (optional)
|
||||
* `options` {Object} Additional `Error` options merged with `cause`. (optional)
|
||||
|
||||
## Class: `SecureProxyConnectionError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4472
|
||||
description: Made the error reliable with `instanceof`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
A TLS connection to a proxy failed.
|
||||
|
||||
* `name` {string} Always `'SecureProxyConnectionError'`.
|
||||
* `code` {string} Always `'UND_ERR_PRX_TLS'`.
|
||||
* `cause` {Error} The underlying error that caused the proxy connection to fail.
|
||||
|
||||
### `new SecureProxyConnectionError(cause[, message[, options]])`
|
||||
|
||||
* `cause` {Error} The underlying error. (optional)
|
||||
* `message` {string} The error message. (optional)
|
||||
* `options` {Object} Additional `Error` options merged with `cause`. (optional)
|
||||
|
||||
## Class: `MaxOriginsReachedError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4365
|
||||
description: Added to support capping the number of origins in `Agent`.
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
The maximum number of allowed origins has been reached.
|
||||
|
||||
* `name` {string} Always `'MaxOriginsReachedError'`.
|
||||
* `code` {string} Always `'UND_ERR_MAX_ORIGINS_REACHED'`.
|
||||
|
||||
## Class: `Socks5ProxyError`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.23.0
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
An error occurred during SOCKS5 proxy negotiation.
|
||||
|
||||
* `name` {string} Always `'Socks5ProxyError'`.
|
||||
* `code` {string} The error code. **Default:** `'UND_ERR_SOCKS5'`.
|
||||
|
||||
### `new Socks5ProxyError([message[, code]])`
|
||||
|
||||
* `message` {string} The error message. (optional)
|
||||
* `code` {string} The error code. **Default:** `'UND_ERR_SOCKS5'`. (optional)
|
||||
|
||||
## Class: `MessageSizeExceededError`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
A decompressed WebSocket message exceeded the maximum allowed size.
|
||||
|
||||
* `name` {string} Always `'MessageSizeExceededError'`.
|
||||
* `code` {string} Always `'UND_ERR_WS_MESSAGE_SIZE_EXCEEDED'`.
|
||||
|
||||
[`BalancedPool`]: BalancedPool.md#class-balancedpool
|
||||
[`HTTPParserError`]: #class-httpparsererror
|
||||
[`RequestAbortedError`]: #class-requestabortederror
|
||||
[`UndiciError`]: #class-undicierror
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
# EventSource
|
||||
|
||||
<!--introduced_in=v6.5.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/web/eventsource/eventsource.js -->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
`EventSource` is a [WHATWG-conformant][] implementation of the
|
||||
[server-sent events][] interface. It opens a persistent HTTP connection to a
|
||||
server that responds with the `text/event-stream` content type and dispatches
|
||||
the events it receives without closing the connection.
|
||||
|
||||
```mjs
|
||||
import { EventSource } from 'undici'
|
||||
|
||||
const eventSource = new EventSource('http://localhost:3000')
|
||||
eventSource.onmessage = (event) => {
|
||||
console.log(event.data)
|
||||
}
|
||||
```
|
||||
|
||||
Constructing an `EventSource` for the first time emits a one-time
|
||||
`ExperimentalWarning` with the code `'UNDICI-ES'`. The interface is also
|
||||
installed onto `globalThis` as `globalThis.EventSource`.
|
||||
|
||||
## Class: `EventSource`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.5.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Extends: {EventTarget}
|
||||
|
||||
The `EventSource` interface receives server-sent events over an HTTP
|
||||
connection. Instances are created with the [`new EventSource()`](#new-eventsourceurl-eventsourceinitdict)
|
||||
constructor and emit [`'open'`](#event-open), [`'message'`](#event-message), and
|
||||
[`'error'`](#event-error) events.
|
||||
|
||||
### `new EventSource(url[, eventSourceInitDict])`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.5.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* `url` {string|URL} The URL of the event stream. Relative URLs are resolved
|
||||
against the environment's base URL.
|
||||
* `eventSourceInitDict` {Object} (optional)
|
||||
* `withCredentials` {boolean} When `true`, the request is made with the
|
||||
credentials mode set to `include` and CORS attribute state set to
|
||||
`use-credentials`; otherwise the credentials mode is `same-origin`.
|
||||
**Default:** `false`.
|
||||
* `dispatcher` {Dispatcher} The dispatcher used for the underlying request.
|
||||
**Default:** the global dispatcher.
|
||||
|
||||
> Stability: 0 - Deprecated. Use `node.dispatcher` instead.
|
||||
* `node` {Object} undici-specific extensions to the standard
|
||||
`EventSourceInit` dictionary.
|
||||
* `dispatcher` {Dispatcher} The dispatcher used for the underlying request.
|
||||
**Default:** the global dispatcher.
|
||||
* `reconnectionTime` {number} The reconnection time, in milliseconds, to
|
||||
wait before re-establishing a dropped connection. The server may override
|
||||
this value with a `retry` field. **Default:** `3000`.
|
||||
|
||||
EventSource-specific limits can be configured on the dispatcher using the
|
||||
`eventSource` option. See [`Client`][] for details.
|
||||
|
||||
Creates a new `EventSource` and immediately begins connecting to `url`. The
|
||||
request is sent with the `Accept: text/event-stream` header, a cache mode of
|
||||
`no-store`, and an initiator type of `other`.
|
||||
|
||||
If `url` cannot be parsed, a `SyntaxError` `DOMException` is thrown.
|
||||
|
||||
```mjs
|
||||
import { EventSource } from 'undici'
|
||||
|
||||
const eventSource = new EventSource('http://localhost:3000', {
|
||||
withCredentials: true
|
||||
})
|
||||
```
|
||||
|
||||
A custom [`Dispatcher`][] can be supplied to control the underlying request, for
|
||||
example to add request headers:
|
||||
|
||||
```mjs
|
||||
import { EventSource, Agent } from 'undici'
|
||||
|
||||
class CustomHeaderAgent extends Agent {
|
||||
dispatch (opts) {
|
||||
opts.headers['x-custom-header'] = 'hello world'
|
||||
return super.dispatch(...arguments)
|
||||
}
|
||||
}
|
||||
|
||||
const eventSource = new EventSource('http://localhost:3000', {
|
||||
node: {
|
||||
dispatcher: new CustomHeaderAgent()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### `eventSource.close()`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.5.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Returns: {undefined}
|
||||
|
||||
Closes the connection, if any, aborts the underlying request, and sets
|
||||
[`eventSource.readyState`](#eventsourcereadystate) to `CLOSED`. Once closed, the
|
||||
`EventSource` does not attempt to reconnect. Calling `close()` on an already
|
||||
closed `EventSource` has no effect.
|
||||
|
||||
### `eventSource.readyState`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.5.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Type: {number}
|
||||
|
||||
A read-only number representing the state of the connection. It is one of the
|
||||
following constants:
|
||||
|
||||
* [`EventSource.CONNECTING`](#eventsourceconnecting) (`0`) — the connection has
|
||||
not yet been established, or it was closed and is being re-established.
|
||||
* [`EventSource.OPEN`](#eventsourceopen) (`1`) — the connection is open and
|
||||
events are being dispatched as they are received.
|
||||
* [`EventSource.CLOSED`](#eventsourceclosed) (`2`) — the connection is not open
|
||||
and is not being re-established.
|
||||
|
||||
### `eventSource.url`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.5.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Type: {string}
|
||||
|
||||
A read-only string providing the URL of the event stream, after resolution
|
||||
against the environment's base URL.
|
||||
|
||||
### `eventSource.withCredentials`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.5.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Type: {boolean}
|
||||
|
||||
A read-only boolean indicating whether the `EventSource` was instantiated with
|
||||
CORS credentials (`true`), or not (`false`, the default). It reflects the
|
||||
`withCredentials` option passed to the constructor.
|
||||
|
||||
### `eventSource.onopen`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.5.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Type: {Function|null} **Default:** `null`.
|
||||
|
||||
An event handler that is invoked when an [`'open'`](#event-open) event is
|
||||
dispatched. Assigning a function registers it as the handler; assigning `null`
|
||||
removes the current handler.
|
||||
|
||||
### `eventSource.onmessage`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.5.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Type: {Function|null} **Default:** `null`.
|
||||
|
||||
An event handler that is invoked when a [`'message'`](#event-message) event is
|
||||
dispatched. Assigning a function registers it as the handler; assigning `null`
|
||||
removes the current handler.
|
||||
|
||||
### `eventSource.onerror`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.5.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Type: {Function|null} **Default:** `null`.
|
||||
|
||||
An event handler that is invoked when an [`'error'`](#event-error) event is
|
||||
dispatched. Assigning a function registers it as the handler; assigning `null`
|
||||
removes the current handler.
|
||||
|
||||
### `EventSource.CONNECTING`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.5.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Type: {number}
|
||||
|
||||
The numeric constant `0`, representing the `CONNECTING`
|
||||
[`readyState`](#eventsourcereadystate). It is defined as a read-only,
|
||||
non-writable property on both the `EventSource` constructor and instances.
|
||||
|
||||
### `EventSource.OPEN`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.5.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Type: {number}
|
||||
|
||||
The numeric constant `1`, representing the `OPEN`
|
||||
[`readyState`](#eventsourcereadystate). It is defined as a read-only,
|
||||
non-writable property on both the `EventSource` constructor and instances.
|
||||
|
||||
### `EventSource.CLOSED`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.5.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Type: {number}
|
||||
|
||||
The numeric constant `2`, representing the `CLOSED`
|
||||
[`readyState`](#eventsourcereadystate). It is defined as a read-only,
|
||||
non-writable property on both the `EventSource` constructor and instances.
|
||||
|
||||
### Event: `'open'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.5.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
Emitted when the connection is established and the
|
||||
[`readyState`](#eventsourcereadystate) becomes `OPEN`. The listener receives an
|
||||
{Event}.
|
||||
|
||||
```mjs
|
||||
import { EventSource } from 'undici'
|
||||
|
||||
const eventSource = new EventSource('http://localhost:3000')
|
||||
eventSource.addEventListener('open', () => {
|
||||
console.log('connection opened')
|
||||
})
|
||||
```
|
||||
|
||||
### Event: `'message'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.15.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
Emitted when a message that has no explicit `event` field is received. The
|
||||
listener receives a {MessageEvent} whose `data`, `lastEventId`, and `origin`
|
||||
properties are populated from the server-sent event. Named events (those with an
|
||||
`event` field) are dispatched under their own type and must be subscribed to with
|
||||
[`addEventListener()`][].
|
||||
|
||||
```mjs
|
||||
import { createServer } from 'node:http'
|
||||
import { EventSource } from 'undici'
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
|
||||
response.write('event: ping\n')
|
||||
response.write('data: connected\n\n')
|
||||
|
||||
const interval = setInterval(() => {
|
||||
response.write(`data: ${Date.now()}\n\n`)
|
||||
}, 1000)
|
||||
|
||||
request.on('close', () => clearInterval(interval))
|
||||
})
|
||||
|
||||
server.listen(3000, () => {
|
||||
const eventSource = new EventSource('http://localhost:3000')
|
||||
|
||||
// Named event, delivered under its own type.
|
||||
eventSource.addEventListener('ping', (event) => {
|
||||
console.log('ping:', event.data)
|
||||
})
|
||||
|
||||
// Unnamed event, delivered as 'message'.
|
||||
eventSource.onmessage = (event) => {
|
||||
console.log('message:', event.data)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Event: `'error'`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.5.0
|
||||
changes:
|
||||
- version: v7.11.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4247
|
||||
description: The connection is no longer re-established after a network
|
||||
error; the `EventSource` is closed instead.
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
Emitted when the connection fails or is interrupted. The listener receives an
|
||||
{Event}. When the failure is recoverable, the `EventSource` transitions back to
|
||||
`CONNECTING` and retries after the reconnection time; when it is not, the
|
||||
`EventSource` transitions to `CLOSED` and does not reconnect.
|
||||
|
||||
```mjs
|
||||
import { EventSource } from 'undici'
|
||||
|
||||
const eventSource = new EventSource('http://localhost:3000')
|
||||
eventSource.onerror = () => {
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
console.log('connection closed')
|
||||
} else {
|
||||
console.log('reconnecting')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[WHATWG-conformant]: https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
|
||||
[`Client`]: Client.md#new-clienturl-options
|
||||
[`Dispatcher`]: Dispatcher.md#class-dispatcher
|
||||
[`addEventListener()`]: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
|
||||
[server-sent events]: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
|
||||
+721
@@ -0,0 +1,721 @@
|
||||
# Fetch
|
||||
|
||||
<!--introduced_in=v1.0.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/web/fetch/index.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
undici implements the [WHATWG Fetch Standard][], providing `fetch()` together
|
||||
with the `Request`, `Response`, `Headers`, and `FormData` classes that mirror
|
||||
the browser APIs. The implementation follows the standard, so the
|
||||
[MDN Fetch documentation][] applies as well.
|
||||
|
||||
```mjs
|
||||
import { fetch, Request, Response, Headers, FormData } from 'undici'
|
||||
|
||||
const response = await fetch('https://example.com')
|
||||
const text = await response.text()
|
||||
```
|
||||
|
||||
When mixing these classes, keep them from the same implementation: use the
|
||||
global `fetch()` with the global `FormData`, `Request`, `Response`, and
|
||||
`Headers`, and use undici's `fetch()` with undici's classes. Passing a value
|
||||
created by one implementation to the other can throw.
|
||||
|
||||
## `fetch(input[, init])`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.6.0
|
||||
changes:
|
||||
- version: v5.6.1
|
||||
pr-url: https://github.com/nodejs/undici/pull/1529
|
||||
description: No default value is assigned to `init.method`.
|
||||
-->
|
||||
|
||||
* `input` {string|URL|Request} The resource to fetch. A string or {URL} is
|
||||
treated as the URL to request; a {Request} is used as the request template.
|
||||
* `init` {RequestInit} (optional) An options object that customizes the
|
||||
request.
|
||||
* `body` {string|Buffer|Uint8Array|Blob|FormData|URLSearchParams|ReadableStream|null}
|
||||
The request body. **Default:** `null`.
|
||||
* `cache` {string} The cache mode. One of `'default'`, `'force-cache'`,
|
||||
`'no-cache'`, `'no-store'`, `'only-if-cached'`, or `'reload'`.
|
||||
* `credentials` {string} How credentials are sent. One of `'omit'`,
|
||||
`'include'`, or `'same-origin'`.
|
||||
* `dispatcher` {Dispatcher} The {Dispatcher} used to perform the request.
|
||||
**Default:** the global dispatcher.
|
||||
* `duplex` {string} The duplex mode of the request. Must be `'half'` when a
|
||||
streaming `body` is provided.
|
||||
* `headers` {Headers|Object|Array} The request headers, as a {Headers}
|
||||
instance, a plain object, or an array of `[name, value]` pairs.
|
||||
* `integrity` {string} The subresource integrity metadata of the request.
|
||||
* `keepalive` {boolean} Whether the connection may outlive the page.
|
||||
**Default:** `false`.
|
||||
* `method` {string} The request method, for example `'GET'` or `'POST'`.
|
||||
* `mode` {string} The request mode. One of `'cors'`, `'navigate'`,
|
||||
`'no-cors'`, or `'same-origin'`.
|
||||
* `redirect` {string} How redirects are handled. One of `'error'`,
|
||||
`'follow'`, or `'manual'`.
|
||||
* `referrer` {string} The request referrer.
|
||||
* `referrerPolicy` {string} The referrer policy. One of `''`,
|
||||
`'no-referrer'`, `'no-referrer-when-downgrade'`, `'origin'`,
|
||||
`'origin-when-cross-origin'`, `'same-origin'`, `'strict-origin'`,
|
||||
`'strict-origin-when-cross-origin'`, or `'unsafe-url'`.
|
||||
* `signal` {AbortSignal|null} An {AbortSignal} used to abort the request.
|
||||
**Default:** `null`.
|
||||
* `window` {null} Can only be `null`; reserved by the standard.
|
||||
* Returns: {Promise} Fulfills with a {Response} once the response headers have
|
||||
been received.
|
||||
|
||||
Starts the process of fetching a resource from the network and returns a
|
||||
promise that fulfills with a {Response}. The promise rejects only on network
|
||||
failures; an HTTP error status such as `404` still fulfills the promise, so
|
||||
inspect [`response.ok`](#responseok) to detect failures.
|
||||
|
||||
```mjs
|
||||
import { fetch } from 'undici'
|
||||
|
||||
const response = await fetch('https://example.com', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ hello: 'world' }),
|
||||
})
|
||||
|
||||
console.log(response.status)
|
||||
console.log(await response.json())
|
||||
```
|
||||
|
||||
To route the request through a custom {Dispatcher} (for example a `ProxyAgent`
|
||||
or `Agent` with specific options), pass it as `init.dispatcher`.
|
||||
|
||||
```mjs
|
||||
import { fetch, Agent } from 'undici'
|
||||
|
||||
const response = await fetch('https://example.com', {
|
||||
dispatcher: new Agent({ connect: { rejectUnauthorized: false } }),
|
||||
})
|
||||
```
|
||||
|
||||
## Class: `FormData`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.4.4
|
||||
-->
|
||||
|
||||
A set of key/value pairs representing form fields and their values, suitable
|
||||
for use as a `fetch()` request `body`. The implementation follows the
|
||||
[WHATWG Fetch Standard][]; see the [MDN `FormData` documentation][].
|
||||
|
||||
When using `FormData` as a request body, keep `fetch` and `FormData` from the
|
||||
same implementation: use the global `FormData` with the global `fetch()`, and
|
||||
undici's `FormData` with undici's `fetch()`.
|
||||
|
||||
### `new FormData()`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.4.4
|
||||
-->
|
||||
|
||||
Creates a new, empty `FormData` instance. Passing any argument other than
|
||||
`undefined` throws; in particular, an `HTMLFormElement` argument is not
|
||||
supported in this environment.
|
||||
|
||||
### `formData.append(name, value[, filename])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `name` {string} The name of the field.
|
||||
* `value` {string|Blob} The value of the field.
|
||||
* `filename` {string} The filename reported to the server when `value` is a
|
||||
{Blob}. (optional)
|
||||
|
||||
Appends a new value to an existing key, or adds the key if it does not exist.
|
||||
Unlike [`formData.set()`](#formdatasetname-value-filename), `append()` keeps
|
||||
any existing values for `name`.
|
||||
|
||||
### `formData.delete(name)`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `name` {string} The name of the field to remove.
|
||||
|
||||
Deletes all values associated with `name`.
|
||||
|
||||
### `formData.get(name)`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `name` {string} The name of the field to read.
|
||||
* Returns: {string|File|null} The first value associated with `name`, or
|
||||
`null` if there is none.
|
||||
|
||||
### `formData.getAll(name)`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `name` {string} The name of the field to read.
|
||||
* Returns: {Array} All values associated with `name`, as an array of
|
||||
{string} and {File} entries.
|
||||
|
||||
### `formData.has(name)`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `name` {string} The name of the field to look up.
|
||||
* Returns: {boolean} `true` if at least one value is associated with `name`.
|
||||
|
||||
### `formData.set(name, value[, filename])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `name` {string} The name of the field.
|
||||
* `value` {string|Blob} The value of the field.
|
||||
* `filename` {string} The filename reported to the server when `value` is a
|
||||
{Blob}. (optional)
|
||||
|
||||
Sets a new value for an existing key, or adds the key if it does not exist,
|
||||
replacing any values previously associated with `name`.
|
||||
|
||||
## Class: `Response`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.4.0
|
||||
-->
|
||||
|
||||
* Extends: {BodyMixin}
|
||||
|
||||
Represents the response to a request. Instances are typically obtained by
|
||||
awaiting `fetch()`, but can also be constructed directly. The implementation
|
||||
follows the [WHATWG Fetch Standard][]; see the
|
||||
[MDN `Response` documentation][]. `Response` inherits the body-reading methods
|
||||
described in [Body mixin](#body-mixin).
|
||||
|
||||
### `new Response([body[, init]])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.4.0
|
||||
-->
|
||||
|
||||
* `body` {string|Buffer|Uint8Array|Blob|FormData|URLSearchParams|ReadableStream|null}
|
||||
The response body. **Default:** `null`.
|
||||
* `init` {ResponseInit} (optional)
|
||||
* `status` {number} The response status code. **Default:** `200`.
|
||||
* `statusText` {string} The response status message. **Default:** `''`.
|
||||
* `headers` {Headers|Object|Array} The response headers.
|
||||
|
||||
Creates a new `Response`.
|
||||
|
||||
### Static method: `Response.error()`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Returns: {Response} A network-error response.
|
||||
|
||||
Returns a new `Response` representing a network error, with its
|
||||
[`type`](#responsetype) set to `'error'`.
|
||||
|
||||
### Static method: `Response.json(data[, init])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `data` {any} The value to serialize as JSON.
|
||||
* `init` {ResponseInit} (optional)
|
||||
* `status` {number} The response status code. **Default:** `200`.
|
||||
* `statusText` {string} The response status message. **Default:** `''`.
|
||||
* `headers` {Headers|Object|Array} The response headers.
|
||||
* Returns: {Response} A response whose body is the JSON serialization of
|
||||
`data` and whose `Content-Type` is `application/json`.
|
||||
|
||||
```mjs
|
||||
import { Response } from 'undici'
|
||||
|
||||
const response = Response.json({ ok: true }, { status: 201 })
|
||||
```
|
||||
|
||||
### Static method: `Response.redirect(url[, status])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `url` {string|URL} The URL to redirect to.
|
||||
* `status` {number} The redirect status code. One of `301`, `302`, `303`,
|
||||
`307`, or `308`. **Default:** `302`.
|
||||
* Returns: {Response} A redirect response with the `Location` header set to
|
||||
`url`.
|
||||
|
||||
### `response.clone()`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Returns: {Response} A copy of the response.
|
||||
|
||||
Creates a clone of the response. Throws a `TypeError` if the body has already
|
||||
been read or is locked.
|
||||
|
||||
### `response.type`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {string} The response type. One of `'basic'`, `'cors'`, `'default'`,
|
||||
`'error'`, `'opaque'`, or `'opaqueredirect'`.
|
||||
|
||||
### `response.url`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {string} The final URL of the response after any redirects, or the
|
||||
empty string if not available.
|
||||
|
||||
### `response.redirected`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {boolean} `true` if the response is the result of one or more
|
||||
redirects.
|
||||
|
||||
### `response.status`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {number} The HTTP status code of the response.
|
||||
|
||||
### `response.ok`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {boolean} `true` when [`status`](#responsestatus) is in the range
|
||||
`200`–`299`.
|
||||
|
||||
### `response.statusText`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {string} The status message corresponding to the status code.
|
||||
|
||||
### `response.headers`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {Headers} The {Headers} object associated with the response.
|
||||
|
||||
## Class: `Request`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Extends: {BodyMixin}
|
||||
|
||||
Represents a resource request. Instances can be passed to `fetch()` in place
|
||||
of a URL string. The implementation follows the [WHATWG Fetch Standard][]; see
|
||||
the [MDN `Request` documentation][]. `Request` inherits the body-reading
|
||||
methods described in [Body mixin](#body-mixin).
|
||||
|
||||
### `new Request(input[, init])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `input` {string|URL|Request} The resource to request, as a URL string,
|
||||
{URL}, or another {Request} to copy.
|
||||
* `init` {RequestInit} (optional) An options object with the same fields as
|
||||
the `init` argument of [`fetch()`](#fetchinput-init).
|
||||
* Returns: {Request}
|
||||
|
||||
Creates a new `Request`.
|
||||
|
||||
### `request.clone()`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Returns: {Request} A copy of the request.
|
||||
|
||||
Creates a clone of the request. Throws a `TypeError` if the body has already
|
||||
been read or is locked.
|
||||
|
||||
### `request.method`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {string} The request method, for example `'GET'`.
|
||||
|
||||
### `request.url`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {string} The serialized URL of the request.
|
||||
|
||||
### `request.headers`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {Headers} The {Headers} object associated with the request.
|
||||
|
||||
### `request.destination`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {string} The request destination, indicating the type of content being
|
||||
requested, for example `''`, `'image'`, or `'script'`.
|
||||
|
||||
### `request.referrer`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {string} The referrer of the request. May be `'about:client'` or a URL
|
||||
string.
|
||||
|
||||
### `request.referrerPolicy`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {string} The referrer policy of the request.
|
||||
|
||||
### `request.mode`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {string} The mode of the request. One of `'cors'`, `'navigate'`,
|
||||
`'no-cors'`, or `'same-origin'`.
|
||||
|
||||
### `request.credentials`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {string} The credentials mode of the request. One of `'omit'`,
|
||||
`'include'`, or `'same-origin'`.
|
||||
|
||||
### `request.cache`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {string} The cache mode of the request.
|
||||
|
||||
### `request.redirect`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {string} The redirect mode of the request. One of `'error'`,
|
||||
`'follow'`, or `'manual'`.
|
||||
|
||||
### `request.integrity`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {string} The subresource integrity metadata of the request.
|
||||
|
||||
### `request.keepalive`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {boolean} Whether the request may outlive the environment that created
|
||||
it.
|
||||
|
||||
### `request.isReloadNavigation`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {boolean} `true` if the request is a reload navigation.
|
||||
|
||||
### `request.isHistoryNavigation`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {boolean} `true` if the request is a history navigation.
|
||||
|
||||
### `request.signal`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {AbortSignal} The {AbortSignal} associated with the request.
|
||||
|
||||
### `request.duplex`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {string} The duplex mode of the request. Always `'half'`.
|
||||
|
||||
## Class: `Headers`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.4.0
|
||||
-->
|
||||
|
||||
Represents the header list of a request or response and provides methods to
|
||||
read and modify it. The implementation follows the [WHATWG Fetch Standard][];
|
||||
see the [MDN `Headers` documentation][]. `Headers` is iterable, yielding
|
||||
`[name, value]` pairs sorted by name.
|
||||
|
||||
### `new Headers([init])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.4.0
|
||||
-->
|
||||
|
||||
* `init` {Headers|Object|Array} (optional) Initial headers, as a {Headers}
|
||||
instance, a plain object of name/value pairs, or an array of `[name, value]`
|
||||
pairs.
|
||||
|
||||
Creates a new `Headers` object.
|
||||
|
||||
### `headers.append(name, value)`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `name` {string} The name of the header.
|
||||
* `value` {string} The value of the header.
|
||||
|
||||
Appends a value to a header, or adds the header if it does not exist. Existing
|
||||
values for `name` are preserved.
|
||||
|
||||
### `headers.delete(name)`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `name` {string} The name of the header to remove.
|
||||
|
||||
Removes the header named `name`.
|
||||
|
||||
### `headers.get(name)`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `name` {string} The name of the header to read.
|
||||
* Returns: {string|null} The combined values of the header, or `null` if it is
|
||||
not present.
|
||||
|
||||
### `headers.has(name)`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `name` {string} The name of the header to look up.
|
||||
* Returns: {boolean} `true` if the header is present.
|
||||
|
||||
### `headers.set(name, value)`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `name` {string} The name of the header.
|
||||
* `value` {string} The value of the header.
|
||||
|
||||
Sets a header to a single value, replacing any existing values for `name`.
|
||||
|
||||
### `headers.getSetCookie()`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Returns: {string[]} An array of the values of all `Set-Cookie` headers.
|
||||
|
||||
Returns each `Set-Cookie` header as a separate string, without combining them.
|
||||
|
||||
## Body mixin
|
||||
|
||||
`Request` and `Response` both extend {BodyMixin}, which provides methods and
|
||||
properties for reading a body. Each consuming method reads the body once; after
|
||||
the body has been consumed, [`bodyUsed`](#bodybodyused) becomes `true` and
|
||||
calling another consuming method throws a `TypeError`.
|
||||
|
||||
> [!WARNING]
|
||||
> The `arrayBuffer()`, `blob()`, `bytes()`, `formData()`, `json()`, and `text()`
|
||||
> methods buffer the entire body in memory before returning. Where applicable,
|
||||
> they also decode or parse the payload and retain that representation in
|
||||
> memory. Calling these methods therefore means trusting that the body is small
|
||||
> enough to fit in the available memory. Do not use them for bodies received
|
||||
> from untrusted or user-controlled sources. Instead, process `body.body` or
|
||||
> `body.textStream()` incrementally and enforce an application-specific size
|
||||
> limit.
|
||||
|
||||
### `body.arrayBuffer()`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Returns: {Promise} Fulfills with an {ArrayBuffer} containing the body bytes.
|
||||
|
||||
### `body.blob()`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Returns: {Promise} Fulfills with a {Blob} containing the body.
|
||||
|
||||
### `body.bytes()`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Returns: {Promise} Fulfills with a {Uint8Array} containing the body bytes.
|
||||
|
||||
### `body.formData()`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
> Stability: 0 - Deprecated
|
||||
|
||||
* Returns: {Promise} Fulfills with a {FormData} parsed from the body.
|
||||
|
||||
Buffers and parses the entire body as `multipart/form-data` or
|
||||
`application/x-www-form-urlencoded`. Because multipart parsing has inherent
|
||||
security risks and the whole body is buffered, this method must only be called
|
||||
on responses from trusted servers.
|
||||
|
||||
For responses from untrusted or user-controlled servers, use a dedicated
|
||||
streaming parser such as [@fastify/busboy][] and apply application-specific
|
||||
limits:
|
||||
|
||||
```mjs
|
||||
import { Busboy } from '@fastify/busboy'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
const response = await fetch('...')
|
||||
const busboy = new Busboy({
|
||||
headers: { 'content-type': response.headers.get('content-type') },
|
||||
})
|
||||
|
||||
// Handle the events emitted by `busboy`.
|
||||
|
||||
Readable.fromWeb(response.body).pipe(busboy)
|
||||
```
|
||||
|
||||
### `body.json()`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Returns: {Promise} Fulfills with the result of parsing the body as JSON.
|
||||
|
||||
### `body.text()`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Returns: {Promise} Fulfills with a {string} containing the body decoded as
|
||||
UTF-8.
|
||||
|
||||
### `body.textStream()`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Returns: {ReadableStream} A {ReadableStream} of {string} chunks produced by
|
||||
decoding the body as UTF-8.
|
||||
|
||||
An undici-specific extension that exposes the body as a stream of decoded text
|
||||
chunks rather than buffering it. It is not part of the [WHATWG Fetch
|
||||
Standard][].
|
||||
|
||||
### `body.body`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {ReadableStream|null} The body as a {ReadableStream}, or `null` if the
|
||||
message has no body.
|
||||
|
||||
### `body.bodyUsed`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {boolean} `true` once the body has been read.
|
||||
|
||||
[@fastify/busboy]: https://www.npmjs.com/package/@fastify/busboy
|
||||
[MDN Fetch documentation]: https://developer.mozilla.org/en-US/docs/Web/API/fetch
|
||||
[MDN `FormData` documentation]: https://developer.mozilla.org/en-US/docs/Web/API/FormData
|
||||
[MDN `Headers` documentation]: https://developer.mozilla.org/en-US/docs/Web/API/Headers
|
||||
[MDN `Request` documentation]: https://developer.mozilla.org/en-US/docs/Web/API/Request
|
||||
[MDN `Response` documentation]: https://developer.mozilla.org/en-US/docs/Web/API/Response
|
||||
[WHATWG Fetch Standard]: https://fetch.spec.whatwg.org/
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
# Global Installation
|
||||
|
||||
<!--introduced_in=v7.11.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=index.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
undici ships its own implementations of several WHATWG web APIs. The `install()`
|
||||
function assigns those implementations onto `globalThis`, so they can be used
|
||||
without importing them from `'undici'` first. This is useful for polyfilling
|
||||
environments, guaranteeing consistent behavior across Node.js versions, and
|
||||
satisfying third-party libraries that expect the relevant classes to be global.
|
||||
|
||||
```mjs
|
||||
import { install } from 'undici'
|
||||
|
||||
install()
|
||||
|
||||
// `fetch`, `Headers`, `Response`, `Request`, etc. now resolve to undici's
|
||||
// implementations.
|
||||
const response = await fetch('https://example.com')
|
||||
```
|
||||
|
||||
```cjs
|
||||
const { install } = require('undici')
|
||||
|
||||
install()
|
||||
```
|
||||
|
||||
## `install()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.11.0
|
||||
-->
|
||||
|
||||
* Returns: {undefined}
|
||||
|
||||
Overwrites the following properties of `globalThis` with undici's
|
||||
implementations:
|
||||
|
||||
* `globalThis.fetch`
|
||||
* `globalThis.Headers`
|
||||
* `globalThis.Response`
|
||||
* `globalThis.Request`
|
||||
* `globalThis.FormData`
|
||||
* `globalThis.WebSocket`
|
||||
* `globalThis.CloseEvent`
|
||||
* `globalThis.ErrorEvent`
|
||||
* `globalThis.MessageEvent`
|
||||
* `globalThis.EventSource`
|
||||
|
||||
Each installed value is the same object exported by `'undici'` under the
|
||||
matching name, so the global identifiers form a single, self-consistent set.
|
||||
The assignment is unconditional: any pre-existing global of the same name,
|
||||
including the Node.js built-in, is replaced. The change persists for the
|
||||
lifetime of the process. Calling `install()` more than once is safe and simply
|
||||
re-assigns the same values.
|
||||
|
||||
Documentation for each installed class lives on its own page: see
|
||||
[`fetch`][], [`WebSocket`][], and [`EventSource`][].
|
||||
|
||||
```mjs
|
||||
import { install } from 'undici'
|
||||
|
||||
install()
|
||||
|
||||
const headers = new Headers([['content-type', 'application/json']])
|
||||
const request = new Request('https://example.com')
|
||||
const formData = new FormData()
|
||||
const ws = new WebSocket('wss://example.com')
|
||||
const eventSource = new EventSource('https://example.com/events')
|
||||
```
|
||||
|
||||
### Pairing `fetch` and `FormData`
|
||||
|
||||
When a request body is a `FormData` instance, the `fetch` and `FormData`
|
||||
implementations must come from the same source. After `install()`, both globals
|
||||
resolve to undici, so they always match:
|
||||
|
||||
```mjs
|
||||
import { install } from 'undici'
|
||||
|
||||
install()
|
||||
|
||||
const body = new FormData()
|
||||
await fetch('https://example.com', { method: 'POST', body })
|
||||
```
|
||||
|
||||
If global installation is not desired, import the matching pair directly from
|
||||
`'undici'` instead:
|
||||
|
||||
```mjs
|
||||
import { fetch, FormData } from 'undici'
|
||||
|
||||
const body = new FormData()
|
||||
await fetch('https://example.com', { method: 'POST', body })
|
||||
```
|
||||
|
||||
Mixing a global `FormData` with `undici.fetch()`, or `undici.FormData` with the
|
||||
built-in global `fetch()`, can produce surprising multipart behavior across
|
||||
Node.js and undici versions. Keep the two paired.
|
||||
|
||||
### Conditional installation
|
||||
|
||||
`install()` can be guarded so undici's `fetch` is only installed when no global
|
||||
`fetch` already exists:
|
||||
|
||||
```mjs
|
||||
import { install } from 'undici'
|
||||
|
||||
if (typeof globalThis.fetch === 'undefined') {
|
||||
install()
|
||||
}
|
||||
|
||||
const response = await fetch('https://example.com')
|
||||
```
|
||||
|
||||
[`EventSource`]: EventSource.md#class-eventsource
|
||||
[`WebSocket`]: WebSocket.md#class-websocket
|
||||
[`fetch`]: Fetch.md
|
||||
+385
@@ -0,0 +1,385 @@
|
||||
# H2CClient
|
||||
|
||||
<!--introduced_in=v7.7.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/dispatcher/h2c-client.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
An `H2CClient` is a [`Dispatcher`][] that speaks HTTP/2 in cleartext (h2c) over a
|
||||
single TCP connection to an `http:` origin, without the protocol upgrade or prior
|
||||
knowledge negotiation that a regular [`Client`][] performs. It is a thin
|
||||
specialization of [`Client`][] that forces `allowH2` and `useH2c` on and aliases
|
||||
`pipelining` to `maxConcurrentStreams`, so multiple requests are multiplexed as
|
||||
HTTP/2 streams rather than pipelined HTTP/1.x requests.
|
||||
|
||||
Use an `H2CClient` when the server is known to accept cleartext HTTP/2 directly,
|
||||
for example a service behind a trusted proxy or in a test environment. Only the
|
||||
`http:` protocol is supported; passing any other protocol throws
|
||||
`InvalidArgumentError`.
|
||||
|
||||
```mjs
|
||||
import { H2CClient } from 'undici'
|
||||
|
||||
const client = new H2CClient('http://localhost:3000')
|
||||
|
||||
const { statusCode, body } = await client.request({ path: '/', method: 'GET' })
|
||||
console.log(statusCode)
|
||||
console.log(await body.text())
|
||||
```
|
||||
|
||||
## Class: `H2CClient`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
changes:
|
||||
- version: v7.17.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4690
|
||||
description: Added support for h2c over Unix domain sockets.
|
||||
-->
|
||||
|
||||
* Extends: {Client}
|
||||
|
||||
`H2CClient` inherits the full instance API of [`Client`][] (and therefore of
|
||||
[`Dispatcher`][]). The sections below cover the surface that is meaningful for an
|
||||
h2c client; refer to [`Dispatcher`][] for the complete semantics of each method
|
||||
and event.
|
||||
|
||||
### `new H2CClient(url[, options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* `url` {URL|string} The origin to connect to. Should include only the protocol,
|
||||
hostname, and port. Only the `http:` protocol is supported.
|
||||
* `options` {H2CClientOptions} (optional)
|
||||
* `maxConcurrentStreams` {number} The maximum number of concurrent HTTP/2
|
||||
streams for the session. It is advertised to the server and may be overridden
|
||||
by a remote `SETTINGS` frame. Must be a positive integer. **Default:** `100`.
|
||||
* `pipelining` {number} The number of concurrent requests multiplexed over the
|
||||
single connection. For an `H2CClient` this is aliased to
|
||||
`maxConcurrentStreams` and may not exceed it. Must be a positive integer.
|
||||
**Default:** `100`.
|
||||
* `maxHeaderSize` {number} The maximum length of request headers in bytes.
|
||||
**Default:** Node.js' `--max-http-header-size` or `16384` (16 KiB).
|
||||
* `headersTimeout` {number} The amount of time, in milliseconds, the parser
|
||||
waits to receive the complete HTTP headers. **Default:** `300e3`.
|
||||
* `connectTimeout` {number} The timeout for establishing a socket connection,
|
||||
in milliseconds. Use `0` to disable it entirely. **Default:** `10e3`.
|
||||
* `bodyTimeout` {number} The timeout, in milliseconds, after which a request
|
||||
times out. Monitors the time between received body data. Use `0` to disable
|
||||
it entirely. **Default:** `300e3`.
|
||||
* `keepAliveTimeout` {number} The timeout, in milliseconds, after which a
|
||||
socket without active requests times out. May be overridden by _keep-alive_
|
||||
hints from the server. **Default:** `4e3`.
|
||||
* `keepAliveMaxTimeout` {number} The maximum allowed `keepAliveTimeout`, in
|
||||
milliseconds, when overridden by _keep-alive_ hints from the server.
|
||||
**Default:** `600e3`.
|
||||
* `keepAliveTimeoutThreshold` {number} A number of milliseconds subtracted from
|
||||
server _keep-alive_ hints when overriding `keepAliveTimeout`, to account for
|
||||
timing inaccuracies such as transport latency. **Default:** `1e3`.
|
||||
* `socketPath` {string} An IPC endpoint, either a Unix domain socket or a
|
||||
Windows named pipe. **Default:** `null`.
|
||||
* `strictContentLength` {boolean} If `true`, an error is thrown when the
|
||||
request `content-length` header does not match the length of the request
|
||||
body. **Default:** `true`.
|
||||
* `maxCachedSessions` {number} The maximum number of TLS sessions cached by the
|
||||
built-in connector. Use `0` to disable TLS session caching. **Default:** `100`.
|
||||
* `connect` {Object|Function} Connector options passed to `buildConnector`, or
|
||||
a custom connector function. The `allowH2` option may not be set here.
|
||||
**Default:** `null`.
|
||||
* `maxRequestsPerClient` {number} The maximum number of requests to send over
|
||||
a single connection before it is reset. Use `0` to disable this limit.
|
||||
**Default:** `null`.
|
||||
* `localAddress` {string} The local IP address the socket should connect from.
|
||||
* `maxResponseSize` {number} The maximum allowed response body size in bytes.
|
||||
Use `-1` to disable. **Default:** `-1`.
|
||||
* `autoSelectFamily` {boolean} Enables a family autodetection algorithm that
|
||||
loosely implements section 5 of [RFC 8305][]. Ignored if not supported by the
|
||||
current Node.js version.
|
||||
* `autoSelectFamilyAttemptTimeout` {number} The amount of time, in
|
||||
milliseconds, to wait for a connection attempt to finish before trying the
|
||||
next address when `autoSelectFamily` is enabled.
|
||||
|
||||
Creates a new `H2CClient` for the given origin. The client does not connect until
|
||||
a request is queued; call [`h2cClient.connect()`](#h2cclientconnectoptions-callback)
|
||||
to connect eagerly.
|
||||
|
||||
```mjs
|
||||
import { H2CClient } from 'undici'
|
||||
|
||||
const client = new H2CClient('http://localhost:3000')
|
||||
```
|
||||
|
||||
### `h2cClient.close([callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* `callback` {Function} (optional) Invoked once all queued requests have settled
|
||||
and the client is closed.
|
||||
* Returns: {Promise|undefined} A `Promise` that resolves once the client is
|
||||
closed, when `callback` is not provided.
|
||||
|
||||
Closes the client and gracefully waits for enqueued requests to complete before
|
||||
resolving. See [`dispatcher.close([callback])`][].
|
||||
|
||||
### `h2cClient.destroy([error[, callback]])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* `error` {Error} (optional) The error with which to reject pending requests.
|
||||
**Default:** `null`.
|
||||
* `callback` {Function} (optional) Invoked once the client is destroyed.
|
||||
* Returns: {Promise|undefined} A `Promise` that resolves once the client is
|
||||
destroyed, when `callback` is not provided.
|
||||
|
||||
Destroys the client abruptly, aborting any pending requests. Waits until the
|
||||
socket is closed before invoking `callback` (or resolving the returned `Promise`).
|
||||
See [`dispatcher.destroy([error[, callback]])`][].
|
||||
|
||||
### `h2cClient.connect(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* `options` {Object}
|
||||
* `path` {string}
|
||||
* `headers` {Object} (optional) **Default:** `null`.
|
||||
* `signal` {AbortSignal|EventEmitter|null} (optional) **Default:** `null`.
|
||||
* `opaque` {any} (optional) An opaque value passed through to the returned
|
||||
connect data.
|
||||
* `responseHeaders` {string|null} (optional) Set to `'raw'` to return the
|
||||
response headers as a raw array instead of an object. **Default:** `null`.
|
||||
* `callback` {Function} (optional) Invoked with `(err, data)` once the connection
|
||||
is established. If omitted, a `Promise` is returned instead.
|
||||
* Returns: {Promise|undefined} A `Promise` resolving to the connect data, when
|
||||
`callback` is not provided.
|
||||
|
||||
Starts an HTTP `CONNECT` request. See [`dispatcher.connect(options[, callback])`][].
|
||||
|
||||
### `h2cClient.dispatch(options, handlers)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* `options` {Object} The request options.
|
||||
* `handlers` {Object} The dispatch handler callbacks.
|
||||
* Returns: {boolean} `false` if the dispatcher is busy and the caller should wait
|
||||
for the [`'drain'`](#event-drain) event before dispatching again.
|
||||
|
||||
The lowest-level request API. All other request methods are implemented on top of
|
||||
it. See [`dispatcher.dispatch(options, handler)`][].
|
||||
|
||||
### `h2cClient.pipeline(options, handler)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* `options` {Object} The request options.
|
||||
* `handler` {Function} A handler that receives the response data and returns a
|
||||
{Readable} body.
|
||||
* Returns: {Duplex} A duplex stream for the request body and response body.
|
||||
|
||||
Sends a request and returns a duplex stream, suitable for piping. See
|
||||
[`dispatcher.pipeline(options, handler)`][].
|
||||
|
||||
### `h2cClient.request(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* `options` {Object} The request options.
|
||||
* `callback` {Function} (optional) Invoked with `(err, data)` once the response
|
||||
is received. If omitted, a `Promise` is returned instead.
|
||||
* Returns: {Promise|undefined} A `Promise` resolving to the response data, when
|
||||
`callback` is not provided.
|
||||
|
||||
Performs an HTTP request. See [`dispatcher.request(options[, callback])`][].
|
||||
|
||||
### `h2cClient.stream(options, factory[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* `options` {Object} The request options.
|
||||
* `factory` {Function} A factory that returns a {Writable} to which the response
|
||||
body is written.
|
||||
* `callback` {Function} (optional) Invoked once the request completes. If
|
||||
omitted, a `Promise` is returned instead.
|
||||
* Returns: {Promise|undefined} A `Promise` that resolves once the request
|
||||
completes, when `callback` is not provided.
|
||||
|
||||
Sends a request and writes the response body to the stream returned by `factory`.
|
||||
See [`dispatcher.stream(options, factory[, callback])`][].
|
||||
|
||||
### `h2cClient.upgrade(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* `options` {Object} The request options.
|
||||
* `callback` {Function} (optional) Invoked with `(err, data)` once the upgrade
|
||||
completes. If omitted, a `Promise` is returned instead.
|
||||
* Returns: {Promise|undefined} A `Promise` resolving to the upgrade data, when
|
||||
`callback` is not provided.
|
||||
|
||||
Upgrades a connection to a different protocol. See
|
||||
[`dispatcher.upgrade(options[, callback])`][].
|
||||
|
||||
### `h2cClient.closed`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* Type: {boolean}
|
||||
|
||||
`true` after [`h2cClient.close()`](#h2cclientclosecallback) has been called.
|
||||
|
||||
### `h2cClient.destroyed`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* Type: {boolean}
|
||||
|
||||
`true` after [`h2cClient.destroy()`](#h2cclientdestroyerror-callback) has been
|
||||
called, or after [`h2cClient.close()`](#h2cclientclosecallback) has been called
|
||||
and the client shutdown has completed.
|
||||
|
||||
### `h2cClient.pipelining`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* Type: {number}
|
||||
|
||||
Gets and sets the pipelining factor, i.e. the number of requests multiplexed over
|
||||
the connection.
|
||||
|
||||
### Event: `'connect'`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* `origin` {URL}
|
||||
* `targets` {Array<Dispatcher>}
|
||||
|
||||
Emitted when the socket has been created and connected. The client connects once
|
||||
`client.size > 0`. See [Dispatcher Event: `'connect'`][].
|
||||
|
||||
```mjs
|
||||
import { createServer } from 'node:http2'
|
||||
import { once } from 'node:events'
|
||||
import { H2CClient } from 'undici'
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
response.end('Hello, World!')
|
||||
}).listen()
|
||||
|
||||
await once(server, 'listening')
|
||||
|
||||
const client = new H2CClient(`http://localhost:${server.address().port}`)
|
||||
|
||||
client.on('connect', (origin) => {
|
||||
console.log(`Connected to ${origin}`)
|
||||
})
|
||||
|
||||
const { body } = await client.request({ path: '/', method: 'GET' })
|
||||
console.log(await body.text())
|
||||
|
||||
client.close()
|
||||
server.close()
|
||||
```
|
||||
|
||||
### Event: `'disconnect'`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* `origin` {URL}
|
||||
* `targets` {Array<Dispatcher>}
|
||||
* `error` {Error}
|
||||
|
||||
Emitted when the socket has disconnected. The `error` argument is the error that
|
||||
caused the socket to disconnect. The client reconnects if (or once)
|
||||
`client.size > 0`. See [Dispatcher Event: `'disconnect'`][].
|
||||
|
||||
```mjs
|
||||
import { createServer } from 'node:http2'
|
||||
import { once } from 'node:events'
|
||||
import { H2CClient } from 'undici'
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
response.destroy()
|
||||
}).listen()
|
||||
|
||||
await once(server, 'listening')
|
||||
|
||||
const client = new H2CClient(`http://localhost:${server.address().port}`)
|
||||
|
||||
client.on('disconnect', (origin) => {
|
||||
console.log(`Disconnected from ${origin}`)
|
||||
})
|
||||
|
||||
try {
|
||||
await client.request({ path: '/', method: 'GET' })
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
} finally {
|
||||
client.close()
|
||||
server.close()
|
||||
}
|
||||
```
|
||||
|
||||
### Event: `'drain'`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* `origin` {URL}
|
||||
|
||||
Emitted when the client is no longer busy and can accept more requests. See
|
||||
[Dispatcher Event: `'drain'`][].
|
||||
|
||||
### Event: `'error'`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.7.0
|
||||
-->
|
||||
|
||||
* `error` {Error}
|
||||
|
||||
Emitted for user errors, such as an error thrown from an `onResponseError`
|
||||
handler.
|
||||
|
||||
[RFC 8305]: https://tools.ietf.org/html/rfc8305#section-5
|
||||
[Dispatcher Event: `'connect'`]: Dispatcher.md#event-connect
|
||||
[Dispatcher Event: `'disconnect'`]: Dispatcher.md#event-disconnect
|
||||
[Dispatcher Event: `'drain'`]: Dispatcher.md#event-drain
|
||||
[`Client`]: Client.md#class-client
|
||||
[`Dispatcher`]: Dispatcher.md#class-dispatcher
|
||||
[`dispatcher.close([callback])`]: Dispatcher.md#dispatcherclosecallback
|
||||
[`dispatcher.connect(options[, callback])`]: Dispatcher.md#dispatcherconnectoptions-callback
|
||||
[`dispatcher.destroy([error[, callback]])`]: Dispatcher.md#dispatcherdestroyerror-callback
|
||||
[`dispatcher.dispatch(options, handler)`]: Dispatcher.md#dispatcherdispatchoptions-handler
|
||||
[`dispatcher.pipeline(options, handler)`]: Dispatcher.md#dispatcherpipelineoptions-handler
|
||||
[`dispatcher.request(options[, callback])`]: Dispatcher.md#dispatcherrequestoptions-callback
|
||||
[`dispatcher.stream(options, factory[, callback])`]: Dispatcher.md#dispatcherstreamoptions-factory-callback
|
||||
[`dispatcher.upgrade(options[, callback])`]: Dispatcher.md#dispatcherupgradeoptions-callback
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
# Interceptors
|
||||
|
||||
Undici ships with a set of built-in interceptors that can be composed via
|
||||
`dispatcher.compose()` to add cross-cutting behaviour such as automatic
|
||||
retries, response decompression, redirect following, DNS caching, and more.
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import { Agent, interceptors } from 'undici'
|
||||
|
||||
const { retry, redirect, decompress, dump, responseError, dns, cache, deduplicate } = interceptors
|
||||
|
||||
const agent = new Agent().compose([
|
||||
retry({ maxRetries: 3 }),
|
||||
redirect({ maxRedirections: 5 }),
|
||||
decompress()
|
||||
])
|
||||
|
||||
const response = await agent.request({ origin: 'https://example.com', path: '/', method: 'GET' })
|
||||
```
|
||||
|
||||
You can also apply interceptors to a single `Client` or `Pool`:
|
||||
|
||||
```js
|
||||
import { Client, interceptors } from 'undici'
|
||||
|
||||
const client = new Client('https://example.com').compose(
|
||||
interceptors.retry({ maxRetries: 2 })
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom interceptors
|
||||
|
||||
Custom interceptors use the same shape as
|
||||
[`dispatcher.compose()`](./Dispatcher.md#dispatchercomposeinterceptors-interceptor):
|
||||
an interceptor takes a `dispatch` function and returns another dispatch-like
|
||||
function with the same `(options, handler)` signature.
|
||||
|
||||
When an interceptor wraps the handler, forward the callbacks that it does not
|
||||
handle itself. The complete handler callback list is documented under
|
||||
[`dispatcher.dispatch(options, handler)`](./Dispatcher.md#dispatcherdispatchoptions-handler).
|
||||
|
||||
```js
|
||||
import { Agent } from 'undici'
|
||||
|
||||
const timingInterceptor = dispatch => {
|
||||
return (options, handler) => {
|
||||
const started = performance.now()
|
||||
|
||||
return dispatch(options, {
|
||||
...handler,
|
||||
onResponseStart (controller, statusCode, headers, statusMessage) {
|
||||
const duration = Math.round(performance.now() - started)
|
||||
const method = options.method ?? 'GET'
|
||||
const origin = options.origin ?? ''
|
||||
|
||||
console.log(`${method} ${origin}${options.path} -> ${statusCode} in ${duration}ms`)
|
||||
|
||||
return handler.onResponseStart?.(
|
||||
controller,
|
||||
statusCode,
|
||||
headers,
|
||||
statusMessage
|
||||
)
|
||||
},
|
||||
onResponseError (controller, error) {
|
||||
const duration = Math.round(performance.now() - started)
|
||||
|
||||
console.error(`request failed after ${duration}ms`, error)
|
||||
|
||||
return handler.onResponseError?.(controller, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const dispatcher = new Agent().compose(timingInterceptor)
|
||||
|
||||
const { body } = await dispatcher.request({
|
||||
origin: 'https://example.com',
|
||||
path: '/',
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
await body.dump()
|
||||
await dispatcher.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `interceptors.dump([opts])`
|
||||
|
||||
Reads and discards the response body up to a configurable size limit. Useful
|
||||
for keeping a connection alive after an error response without reading the
|
||||
body yourself.
|
||||
|
||||
**Parameters**
|
||||
|
||||
* `opts` {Object} (optional)
|
||||
* `maxSize` {number} Maximum number of bytes to read and discard. Responses
|
||||
whose declared or received body size exceeds this value are aborted.
|
||||
**Default:** `1_048_576` (1 MiB).
|
||||
|
||||
Per-request override: set `dumpMaxSize` on the dispatch options to override
|
||||
the global `maxSize` for a specific request.
|
||||
|
||||
**Returns:** {Dispatcher.DispatcherComposeInterceptor}
|
||||
|
||||
**Example**
|
||||
|
||||
```js
|
||||
import { Agent, interceptors } from 'undici'
|
||||
|
||||
const agent = new Agent().compose(
|
||||
interceptors.dump({ maxSize: 128 * 1024 }) // discard up to 128 KiB
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `interceptors.retry([opts])`
|
||||
|
||||
Automatically retries failed requests using the same options accepted by
|
||||
[`RetryHandler`](./RetryHandler.md).
|
||||
|
||||
**Parameters**
|
||||
|
||||
* `opts` {RetryHandler.RetryOptions} (optional) Global retry options applied
|
||||
to every request. Individual requests can override via `opts.retryOptions`.
|
||||
See [`RetryOptions`](./RetryHandler.md#retryoptions) for the full list of
|
||||
accepted fields.
|
||||
|
||||
**Returns:** {Dispatcher.DispatcherComposeInterceptor}
|
||||
|
||||
**Example**
|
||||
|
||||
```js
|
||||
import { Agent, interceptors } from 'undici'
|
||||
|
||||
const agent = new Agent().compose(
|
||||
interceptors.retry({
|
||||
maxRetries: 5,
|
||||
minTimeout: 200,
|
||||
maxTimeout: 5000,
|
||||
timeoutFactor: 2,
|
||||
statusCodes: [429, 502, 503, 504]
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `interceptors.redirect([opts])`
|
||||
|
||||
Follows HTTP redirects (3xx responses) automatically.
|
||||
|
||||
**Parameters**
|
||||
|
||||
* `opts` {Object} (optional)
|
||||
* `maxRedirections` {number} Maximum number of redirects to follow. Passing
|
||||
`0` disables redirect following entirely. **Default:** `undefined`
|
||||
(inherits from the per-request `maxRedirections` option).
|
||||
* `throwOnMaxRedirect` {boolean} When `true`, throws an error once the
|
||||
redirect limit is reached instead of returning the final redirect response.
|
||||
**Default:** `false`.
|
||||
* `stripHeadersOnRedirect` {string[]} List of header names to remove from
|
||||
the request when following any redirect. **Default:** `[]`.
|
||||
* `stripHeadersOnCrossOriginRedirect` {string[]} List of header names to
|
||||
remove from the request when following a cross-origin redirect (i.e. the
|
||||
redirect target has a different origin). Useful for stripping
|
||||
`Authorization` on cross-origin hops. **Default:** `[]`.
|
||||
|
||||
Per-request override: any of the four options above can also be set directly
|
||||
on the dispatch options to override the interceptor defaults for a specific
|
||||
request.
|
||||
|
||||
**Returns:** {Dispatcher.DispatcherComposeInterceptor}
|
||||
|
||||
**Example**
|
||||
|
||||
```js
|
||||
import { Agent, interceptors } from 'undici'
|
||||
|
||||
const agent = new Agent().compose(
|
||||
interceptors.redirect({
|
||||
maxRedirections: 10,
|
||||
throwOnMaxRedirect: true,
|
||||
stripHeadersOnCrossOriginRedirect: ['authorization', 'cookie']
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `interceptors.decompress([opts])`
|
||||
|
||||
Automatically decompresses response bodies encoded with `gzip`, `x-gzip`,
|
||||
`br` (Brotli), `deflate`, `compress`, `x-compress`, or `zstd`.
|
||||
|
||||
> **Experimental:** This interceptor is experimental and subject to change.
|
||||
> A one-time `ExperimentalWarning` is emitted on first use.
|
||||
|
||||
**Parameters**
|
||||
|
||||
* `opts` {Object} (optional)
|
||||
* `skipStatusCodes` {number[]} Status codes for which decompression is
|
||||
skipped. **Default:** `[204, 304]`.
|
||||
* `skipErrorResponses` {boolean} When `true`, responses with a status code
|
||||
>= 400 are not decompressed. **Default:** `true`.
|
||||
* `maxSize` {number} Maximum decompressed response size in bytes for each
|
||||
decompression stage. The request fails with a
|
||||
`ResponseExceededMaxSizeError` if a stage exceeds this limit. Set to `0` to
|
||||
disable the limit. **Default:** `0`.
|
||||
|
||||
**Returns:** {Dispatcher.DispatcherComposeInterceptor}
|
||||
|
||||
**Example**
|
||||
|
||||
```js
|
||||
import { Agent, interceptors } from 'undici'
|
||||
|
||||
const agent = new Agent().compose(
|
||||
interceptors.decompress({
|
||||
skipStatusCodes: [204, 304],
|
||||
skipErrorResponses: false, // decompress error bodies too
|
||||
maxSize: 16 * 1024 * 1024 // limit decoded bodies to 16 MiB
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `interceptors.responseError([opts])`
|
||||
|
||||
Converts 4xx/5xx responses into thrown `ResponseError` instances, making it
|
||||
easy to handle HTTP errors with a standard `try/catch` block.
|
||||
|
||||
The error body is automatically decoded for `application/json` and
|
||||
`text/plain` responses. For JSON responses the body is parsed and exposed as
|
||||
`error.body`.
|
||||
|
||||
**Parameters**
|
||||
|
||||
* `opts` {Object} (optional) — currently reserved for future use; may be
|
||||
omitted.
|
||||
|
||||
**Returns:** {Dispatcher.DispatcherComposeInterceptor}
|
||||
|
||||
**Example**
|
||||
|
||||
```js
|
||||
import { Agent, interceptors, errors } from 'undici'
|
||||
|
||||
const agent = new Agent().compose(interceptors.responseError())
|
||||
|
||||
try {
|
||||
await agent.request({ origin: 'https://example.com', path: '/not-found', method: 'GET' })
|
||||
} catch (err) {
|
||||
if (err instanceof errors.ResponseError) {
|
||||
console.error(err.status, err.body)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `interceptors.dns([opts])`
|
||||
|
||||
Caches DNS lookups so that repeated requests to the same origin reuse the
|
||||
resolved IP address instead of performing a fresh lookup every time. Supports
|
||||
dual-stack (IPv4 + IPv6) and custom lookup/storage implementations.
|
||||
|
||||
**Parameters**
|
||||
|
||||
* `opts` {Object} (optional)
|
||||
* `maxTTL` {number} Maximum number of milliseconds a DNS record is cached,
|
||||
regardless of the TTL returned by the resolver. **Default:** `0`
|
||||
(use the TTL from the DNS record).
|
||||
* `maxItems` {number} Maximum number of origins to cache simultaneously.
|
||||
Oldest entries are evicted when the limit is reached. **Default:**
|
||||
`Infinity`.
|
||||
* `dualStack` {boolean} When `true`, both IPv4 (`A`) and IPv6 (`AAAA`)
|
||||
records are looked up and the interceptor picks between them based on
|
||||
`affinity`. **Default:** `true`.
|
||||
* `affinity` {4 | 6 | null} Preferred IP family when `dualStack` is
|
||||
enabled. `null` lets the interceptor alternate between families.
|
||||
**Default:** `null`.
|
||||
* `lookup` {Function} (optional) Custom DNS resolution function with the
|
||||
same signature as `node:dns`'s `lookup` callback form:
|
||||
`(origin, options, callback) => void`.
|
||||
* `pick` {Function} (optional) Custom record-selection function called with
|
||||
`(origin, records, affinity)` to choose which resolved address to use.
|
||||
* `storage` {DNSStorage} (optional) Custom storage backend. Must implement
|
||||
`get`, `set`, `delete`, `full`, and `size`.
|
||||
|
||||
**`DNSStorage` interface**
|
||||
|
||||
```ts
|
||||
interface DNSStorage {
|
||||
size: number
|
||||
get(origin: string): DNSInterceptorOriginRecords | null
|
||||
set(origin: string, records: DNSInterceptorOriginRecords | null, options: { ttl: number }): void
|
||||
delete(origin: string): void
|
||||
full(): boolean
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** {Dispatcher.DispatcherComposeInterceptor}
|
||||
|
||||
**Example**
|
||||
|
||||
```js
|
||||
import { Agent, interceptors } from 'undici'
|
||||
|
||||
const agent = new Agent().compose(
|
||||
interceptors.dns({
|
||||
maxTTL: 60_000, // cache for at most 60 seconds
|
||||
dualStack: true,
|
||||
affinity: 4 // prefer IPv4
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `interceptors.cache([opts])`
|
||||
|
||||
Caches HTTP responses according to RFC 9111 (HTTP Caching). See
|
||||
[`CacheStore`](./CacheStore.md) for information on providing a custom
|
||||
backing store.
|
||||
|
||||
**Parameters**
|
||||
|
||||
* `opts` {CacheHandler.CacheOptions} (optional) See the
|
||||
[`CacheStore`](./CacheStore.md) documentation for accepted fields.
|
||||
|
||||
**Returns:** {Dispatcher.DispatcherComposeInterceptor}
|
||||
|
||||
**Example**
|
||||
|
||||
```js
|
||||
import { Agent, interceptors, cacheStores } from 'undici'
|
||||
|
||||
const agent = new Agent().compose(
|
||||
interceptors.cache({ store: new cacheStores.MemoryCacheStore() })
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `interceptors.deduplicate([opts])`
|
||||
|
||||
Deduplicates concurrent identical requests so that only one is sent over the
|
||||
wire. All waiting callers receive the same response once the in-flight request
|
||||
completes. Only safe HTTP methods (e.g. `GET`, `HEAD`) may be deduplicated.
|
||||
|
||||
**Parameters**
|
||||
|
||||
* `opts` {Object} (optional)
|
||||
* `methods` {string[]} HTTP methods to deduplicate. Must be safe HTTP
|
||||
methods (`GET`, `HEAD`, `OPTIONS`, `TRACE`). **Default:** `['GET']`.
|
||||
* `skipHeaderNames` {string[]} Header names whose presence in a request
|
||||
causes it to bypass deduplication entirely. Matching is
|
||||
case-insensitive. **Default:** `[]`.
|
||||
* `excludeHeaderNames` {string[]} Header names to exclude from the
|
||||
deduplication key. Requests that differ only in these headers are still
|
||||
deduplicated together. Useful for headers like `x-request-id` that vary
|
||||
per request but should not prevent deduplication. Matching is
|
||||
case-insensitive. **Default:** `[]`.
|
||||
* `maxBufferSize` {number} Maximum number of bytes buffered per paused
|
||||
waiting handler. If a waiting handler exceeds this threshold it is failed
|
||||
with an `AbortError` to prevent unbounded memory growth. **Default:**
|
||||
`5_242_880` (5 MiB).
|
||||
|
||||
**Returns:** {Dispatcher.DispatcherComposeInterceptor}
|
||||
|
||||
**Example**
|
||||
|
||||
```js
|
||||
import { Agent, interceptors } from 'undici'
|
||||
|
||||
const agent = new Agent().compose(
|
||||
interceptors.deduplicate({
|
||||
methods: ['GET', 'HEAD'],
|
||||
excludeHeaderNames: ['x-request-id', 'x-trace-id'],
|
||||
maxBufferSize: 2 * 1024 * 1024
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composing multiple interceptors
|
||||
|
||||
Interceptors are applied in the order they appear in the `compose()` call.
|
||||
The first interceptor in the array wraps the outermost layer.
|
||||
|
||||
```js
|
||||
import { Agent, interceptors } from 'undici'
|
||||
|
||||
const agent = new Agent().compose([
|
||||
interceptors.dns({ maxTTL: 30_000 }),
|
||||
interceptors.retry({ maxRetries: 3 }),
|
||||
interceptors.redirect({ maxRedirections: 5 }),
|
||||
interceptors.decompress(),
|
||||
interceptors.responseError()
|
||||
])
|
||||
```
|
||||
|
||||
In the example above the request flow is:
|
||||
|
||||
1. **dns** — resolves and caches the target IP
|
||||
2. **retry** — retries the dispatch on transient failures
|
||||
3. **redirect** — follows any 3xx redirects
|
||||
4. **decompress** — decompresses the response body
|
||||
5. **responseError** — converts 4xx/5xx into thrown errors
|
||||
+622
@@ -0,0 +1,622 @@
|
||||
# MockAgent
|
||||
|
||||
<!--introduced_in=v4.0.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/mock/mock-agent.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A `MockAgent` is a {Dispatcher} that intercepts HTTP requests made through
|
||||
undici and replies with programmed mock responses instead of contacting the
|
||||
network. It is useful for testing code that performs HTTP requests without
|
||||
relying on a live server.
|
||||
|
||||
A `MockAgent` does not intercept requests on its own. Mock responses are
|
||||
registered on the {MockClient} or {MockPool} instances returned by
|
||||
[`mockAgent.get(origin)`][], and requests are routed through them once the
|
||||
`MockAgent` is set as the dispatcher (for example through
|
||||
[`setGlobalDispatcher()`][] or a per-request `dispatcher` option).
|
||||
|
||||
> [!NOTE]
|
||||
> [`setGlobalDispatcher()`][] only affects undici APIs that use the global
|
||||
> dispatcher, such as `request()` and `fetch()`. It does not replace or
|
||||
> monkeypatch {Pool} or {Client} instances that were created separately. To test
|
||||
> code that accepts or creates a pool/client directly, pass the {MockPool} or
|
||||
> {MockClient} returned by [`mockAgent.get(origin)`][] into that code.
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
```
|
||||
|
||||
## Class: `MockAgent`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Extends: {Dispatcher}
|
||||
|
||||
Intercepts HTTP requests made through undici and returns mocked responses.
|
||||
|
||||
### `new MockAgent([options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
changes:
|
||||
- version: v7.16.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4479
|
||||
description: Mock options are always normalized to an object internally.
|
||||
-->
|
||||
|
||||
* `options` {MockAgentOptions} (optional)
|
||||
* Returns: {MockAgent}
|
||||
|
||||
When instantiated, a `MockAgent` is automatically activated. It does not
|
||||
intercept any request until mock interceptors are registered on the dispatchers
|
||||
returned by [`mockAgent.get(origin)`][].
|
||||
|
||||
#### Parameter: `MockAgentOptions`
|
||||
|
||||
* Extends: {AgentOptions}
|
||||
* `agent` {Dispatcher} (optional) A custom agent to be encapsulated by the
|
||||
`MockAgent`. It must implement the `Agent` API (that is, expose a `dispatch`
|
||||
function). **Default:** a new {Agent} constructed from `options`.
|
||||
* `ignoreTrailingSlash` {boolean} (optional) Whether trailing slashes in the
|
||||
request path are ignored when matching interceptors. **Default:** `false`.
|
||||
* `acceptNonStandardSearchParameters` {boolean} (optional) Whether the matcher
|
||||
also accepts URLs using non-standard search-parameter syntaxes, such as
|
||||
multi-value items written with `[]` (for example `param[]=1¶m[]=2`) or
|
||||
comma-separated values (for example `param=1,2,3`). **Default:** `false`.
|
||||
* `enableCallHistory` {boolean} (optional) Whether call history recording is
|
||||
enabled. See [`mockAgent.getCallHistory()`][]. **Default:** `false`.
|
||||
|
||||
```mjs displayName="Basic instantiation"
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
```
|
||||
|
||||
```mjs displayName="Instantiation with a custom agent"
|
||||
import { Agent, MockAgent } from 'undici'
|
||||
|
||||
const agent = new Agent()
|
||||
|
||||
const mockAgent = new MockAgent({ agent })
|
||||
```
|
||||
|
||||
### `mockAgent.get(origin)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `origin` {string|RegExp|Function} A matcher for the origin to retrieve. The
|
||||
function form has the signature `(origin) => boolean`.
|
||||
* Returns: {MockClient|MockPool}
|
||||
|
||||
Creates and retrieves the mock dispatcher used to intercept requests for the
|
||||
given origin. When the `connections` option of the `MockAgent` is `1`, a
|
||||
{MockClient} is returned; otherwise a {MockPool} is returned. Subsequent calls
|
||||
with the same origin return the same instance.
|
||||
|
||||
The way `origin` is matched against incoming requests depends on its type:
|
||||
|
||||
| Matcher type | Condition to pass |
|
||||
| :----------: | ---------------------------- |
|
||||
| `string` | Exact match against the value |
|
||||
| `RegExp` | The regular expression matches |
|
||||
| `Function` | The function returns `true` |
|
||||
|
||||
```mjs displayName="Basic mocked request"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
mockPool.intercept({ path: '/foo' }).reply(200, 'foo')
|
||||
|
||||
const { statusCode, body } = await request('http://localhost:3000/foo')
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
|
||||
for await (const data of body) {
|
||||
console.log('data', data.toString('utf8')) // data foo
|
||||
}
|
||||
```
|
||||
|
||||
```mjs displayName="Mocked request using a local dispatcher"
|
||||
import { MockAgent, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
mockPool.intercept({ path: '/foo' }).reply(200, 'foo')
|
||||
|
||||
const { statusCode, body } = await request('http://localhost:3000/foo', {
|
||||
dispatcher: mockAgent
|
||||
})
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
|
||||
for await (const data of body) {
|
||||
console.log('data', data.toString('utf8')) // data foo
|
||||
}
|
||||
```
|
||||
|
||||
```mjs displayName="Testing code that accepts a pool"
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
async function getStatus (pool) {
|
||||
const { statusCode } = await pool.request({
|
||||
path: '/foo',
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
return statusCode
|
||||
}
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockPool.intercept({ path: '/foo', method: 'GET' }).reply(200)
|
||||
|
||||
console.log(await getStatus(mockPool)) // 200
|
||||
```
|
||||
|
||||
```mjs displayName="Returning a MockClient"
|
||||
import { MockAgent, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent({ connections: 1 })
|
||||
|
||||
const mockClient = mockAgent.get('http://localhost:3000')
|
||||
mockClient.intercept({ path: '/foo' }).reply(200, 'foo')
|
||||
|
||||
const { statusCode, body } = await request('http://localhost:3000/foo', {
|
||||
dispatcher: mockClient
|
||||
})
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
|
||||
for await (const data of body) {
|
||||
console.log('data', data.toString('utf8')) // data foo
|
||||
}
|
||||
```
|
||||
|
||||
```mjs displayName="Matching the origin with a regular expression"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get(new RegExp('http://localhost:3000'))
|
||||
mockPool.intercept({ path: '/foo' }).reply(200, 'foo')
|
||||
|
||||
const { statusCode } = await request('http://localhost:3000/foo')
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
```
|
||||
|
||||
```mjs displayName="Matching the origin with a function"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get((origin) => origin === 'http://localhost:3000')
|
||||
mockPool.intercept({ path: '/foo' }).reply(200, 'foo')
|
||||
|
||||
const { statusCode } = await request('http://localhost:3000/foo')
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
```
|
||||
|
||||
### `mockAgent.dispatch(options, handler)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `options` {AgentDispatchOptions}
|
||||
* `handler` {DispatchHandler}
|
||||
* Returns: {boolean}
|
||||
|
||||
Dispatches a mocked request. This implements
|
||||
[`Dispatcher.dispatch()`][] for the encapsulated agent: it ensures the mock
|
||||
dispatcher for `options.origin` exists, records the call in the call history
|
||||
when enabled, and forwards the request to the underlying agent. It is normally
|
||||
invoked indirectly through higher-level methods such as
|
||||
[`mockAgent.request()`][].
|
||||
|
||||
```mjs displayName="Using mockAgent.request()"
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
mockPool.intercept({ path: '/foo' }).reply(200, 'foo')
|
||||
|
||||
const { statusCode, body } = await mockAgent.request({
|
||||
origin: 'http://localhost:3000',
|
||||
path: '/foo',
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
|
||||
for await (const data of body) {
|
||||
console.log('data', data.toString('utf8')) // data foo
|
||||
}
|
||||
```
|
||||
|
||||
### `mockAgent.close()`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Returns: {Promise<void>} Resolves once the agent and its registered mock pools
|
||||
and clients have closed.
|
||||
|
||||
Clears the call history, closes the encapsulated agent, and waits for all
|
||||
registered mock pools and clients to close.
|
||||
|
||||
```mjs displayName="Clean up after tests"
|
||||
import { MockAgent, setGlobalDispatcher } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
await mockAgent.close()
|
||||
```
|
||||
|
||||
### `mockAgent.deactivate()`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Returns: {undefined}
|
||||
|
||||
Disables mocking on the `MockAgent`. While deactivated, requests are no longer
|
||||
intercepted.
|
||||
|
||||
```mjs displayName="Deactivate mocking"
|
||||
import { MockAgent, setGlobalDispatcher } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
mockAgent.deactivate()
|
||||
```
|
||||
|
||||
### `mockAgent.activate()`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Returns: {undefined}
|
||||
|
||||
Enables mocking on the `MockAgent`. A `MockAgent` is activated automatically
|
||||
when instantiated, so this method is only required after
|
||||
[`mockAgent.deactivate()`][] has been called.
|
||||
|
||||
```mjs displayName="Re-activate mocking"
|
||||
import { MockAgent, setGlobalDispatcher } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
mockAgent.deactivate()
|
||||
// No mocking will occur
|
||||
|
||||
// Later
|
||||
mockAgent.activate()
|
||||
```
|
||||
|
||||
### `mockAgent.enableNetConnect([matcher])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `matcher` {string|RegExp|Function} (optional) A host matcher. When a string is
|
||||
used it should only contain the hostname and, optionally, the port. The
|
||||
function form has the signature `(host) => boolean`. When omitted, all
|
||||
non-matching requests are allowed to perform a real request.
|
||||
* Returns: {undefined}
|
||||
|
||||
Defines host matchers so that requests that are not intercepted by a mock
|
||||
dispatcher are allowed to perform a real HTTP request. Calling this method
|
||||
multiple times with a string appends each value to the list of allowed hosts.
|
||||
|
||||
```mjs displayName="Allow all non-matching requests"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
mockAgent.enableNetConnect()
|
||||
|
||||
await request('http://example.com')
|
||||
// A real request is made
|
||||
```
|
||||
|
||||
```mjs displayName="Allow requests matching specific hosts"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
mockAgent.enableNetConnect('example-1.com')
|
||||
mockAgent.enableNetConnect('example-2.com:8080')
|
||||
|
||||
await request('http://example-1.com')
|
||||
// A real request is made
|
||||
|
||||
await request('http://example-2.com:8080')
|
||||
// A real request is made
|
||||
|
||||
await request('http://example-3.com')
|
||||
// Will throw
|
||||
```
|
||||
|
||||
```mjs displayName="Allow requests matching a regular expression or function"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
mockAgent.enableNetConnect(new RegExp('example.com'))
|
||||
mockAgent.enableNetConnect((host) => host === 'example.org')
|
||||
|
||||
await request('http://example.com')
|
||||
// A real request is made
|
||||
```
|
||||
|
||||
### `mockAgent.disableNetConnect()`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Returns: {undefined}
|
||||
|
||||
Causes every request that is not matched by a mock interceptor to throw,
|
||||
disallowing real HTTP requests.
|
||||
|
||||
```mjs displayName="Throw on every non-matching request"
|
||||
import { MockAgent, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
|
||||
mockAgent.disableNetConnect()
|
||||
|
||||
await request('http://example.com')
|
||||
// Will throw
|
||||
```
|
||||
|
||||
### `mockAgent.enableCallHistory()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Returns: {MockAgent} The same `MockAgent` instance, for chaining.
|
||||
|
||||
Enables call history recording. Once enabled, subsequent calls are registered
|
||||
and can be retrieved through [`mockAgent.getCallHistory()`][]. Call history can
|
||||
also be enabled at construction time with the `enableCallHistory` option.
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
|
||||
mockAgent.enableCallHistory()
|
||||
```
|
||||
|
||||
### `mockAgent.disableCallHistory()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Returns: {MockAgent} The same `MockAgent` instance, for chaining.
|
||||
|
||||
Disables call history recording. Subsequent calls are no longer registered.
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent({ enableCallHistory: true })
|
||||
|
||||
mockAgent.disableCallHistory()
|
||||
```
|
||||
|
||||
### `mockAgent.getCallHistory()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Returns: {MockCallHistory|undefined} The {MockCallHistory} instance, or
|
||||
`undefined` when call history is not enabled.
|
||||
|
||||
Returns the call history instance, which records every request made through the
|
||||
`MockAgent` (whether intercepted or not). Call history is not enabled by
|
||||
default; enable it with the `enableCallHistory` option or
|
||||
[`mockAgent.enableCallHistory()`][].
|
||||
|
||||
```mjs displayName="Inspect recorded calls"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent({ enableCallHistory: true })
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
await request('http://example.com', { query: { item: 1 } })
|
||||
|
||||
mockAgent.getCallHistory()?.firstCall()
|
||||
// Returns
|
||||
// MockCallHistoryLog {
|
||||
// body: undefined,
|
||||
// headers: undefined,
|
||||
// method: 'GET',
|
||||
// origin: 'http://example.com',
|
||||
// fullUrl: 'http://example.com/?item=1',
|
||||
// path: '/',
|
||||
// searchParams: { item: '1' },
|
||||
// protocol: 'http:',
|
||||
// host: 'example.com',
|
||||
// port: ''
|
||||
// }
|
||||
```
|
||||
|
||||
### `mockAgent.clearCallHistory()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Returns: {undefined}
|
||||
|
||||
Clears the call history, deleting every recorded {MockCallHistoryLog} on the
|
||||
{MockCallHistory} instance. It is a no-op when call history has never been
|
||||
enabled.
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent({ enableCallHistory: true })
|
||||
|
||||
mockAgent.clearCallHistory()
|
||||
```
|
||||
|
||||
### `mockAgent.pendingInterceptors()`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.1.0
|
||||
-->
|
||||
|
||||
* Returns: {PendingInterceptor[]} A `PendingInterceptor` is a `MockDispatch`
|
||||
with an additional `origin` {string} property.
|
||||
|
||||
Returns the interceptors registered on the `MockAgent` that are still pending.
|
||||
An interceptor is pending when it meets one of the following criteria:
|
||||
|
||||
* It is registered with neither `.times(<number>)` nor `.persist()` and has not
|
||||
been invoked.
|
||||
* It is persistent (registered with `.persist()`) and has not been invoked.
|
||||
* It is registered with `.times(<number>)` and has not been invoked `<number>`
|
||||
times.
|
||||
|
||||
```mjs displayName="List pending interceptors"
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
mockAgent.disableNetConnect()
|
||||
|
||||
mockAgent
|
||||
.get('https://example.com')
|
||||
.intercept({ method: 'GET', path: '/' })
|
||||
.reply(200)
|
||||
|
||||
const pendingInterceptors = mockAgent.pendingInterceptors()
|
||||
// Returns [
|
||||
// {
|
||||
// timesInvoked: 0,
|
||||
// times: 1,
|
||||
// persist: false,
|
||||
// consumed: false,
|
||||
// pending: true,
|
||||
// path: '/',
|
||||
// method: 'GET',
|
||||
// body: undefined,
|
||||
// headers: undefined,
|
||||
// data: {
|
||||
// error: null,
|
||||
// statusCode: 200,
|
||||
// data: '',
|
||||
// headers: {},
|
||||
// trailers: {}
|
||||
// },
|
||||
// origin: 'https://example.com'
|
||||
// }
|
||||
// ]
|
||||
```
|
||||
|
||||
### `mockAgent.assertNoPendingInterceptors([options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.1.0
|
||||
-->
|
||||
|
||||
* `options` {Object} (optional)
|
||||
* `pendingInterceptorsFormatter` {Object} An object exposing a
|
||||
`format(pendingInterceptors)` method used to render the pending interceptors
|
||||
in the thrown error message. **Default:** a built-in formatter that prints a
|
||||
table.
|
||||
* Returns: {undefined}
|
||||
|
||||
Throws an {UndiciError} when the `MockAgent` has any pending interceptors. The
|
||||
criteria for an interceptor being pending are the same as for
|
||||
[`mockAgent.pendingInterceptors()`][].
|
||||
|
||||
```mjs displayName="Assert there are no pending interceptors"
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
mockAgent.disableNetConnect()
|
||||
|
||||
mockAgent
|
||||
.get('https://example.com')
|
||||
.intercept({ method: 'GET', path: '/' })
|
||||
.reply(200)
|
||||
|
||||
mockAgent.assertNoPendingInterceptors()
|
||||
// Throws an UndiciError with the following message:
|
||||
//
|
||||
// 1 interceptor is pending:
|
||||
//
|
||||
// ┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐
|
||||
// │ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │
|
||||
// ├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤
|
||||
// │ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │
|
||||
// └─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘
|
||||
```
|
||||
|
||||
### `mockAgent.isMockActive`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.3.0
|
||||
-->
|
||||
|
||||
* Type: {boolean} `true` while mocking is active, `false` after
|
||||
[`mockAgent.deactivate()`][] has been called.
|
||||
|
||||
A read-only property indicating whether mocking is currently active on the
|
||||
`MockAgent`.
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
|
||||
console.log(mockAgent.isMockActive) // true
|
||||
|
||||
mockAgent.deactivate()
|
||||
|
||||
console.log(mockAgent.isMockActive) // false
|
||||
```
|
||||
|
||||
[`Dispatcher.dispatch()`]: Dispatcher.md#dispatcherdispatchoptions-handler
|
||||
[`mockAgent.deactivate()`]: #mockagentdeactivate
|
||||
[`mockAgent.enableCallHistory()`]: #mockagentenablecallhistory
|
||||
[`mockAgent.get(origin)`]: #mockagentgetorigin
|
||||
[`mockAgent.getCallHistory()`]: #mockagentgetcallhistory
|
||||
[`mockAgent.pendingInterceptors()`]: #mockagentpendinginterceptors
|
||||
[`mockAgent.request()`]: Dispatcher.md#dispatcherrequestoptions-callback
|
||||
[`setGlobalDispatcher()`]: Dispatcher.md#setglobaldispatcherdispatcher
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
# MockCallHistory
|
||||
|
||||
<!--introduced_in=v7.5.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/mock/mock-call-history.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A `MockCallHistory` records the configuration of every request intercepted by a
|
||||
[`MockAgent`][] for which call history is enabled. Each recorded request is
|
||||
stored as a [`MockCallHistoryLog`][], and the `MockCallHistory` exposes helpers
|
||||
to read and filter those logs in tests.
|
||||
|
||||
Instances are not created directly. Enable call history on a [`MockAgent`][] and
|
||||
retrieve the history with `mockAgent.getCallHistory()`:
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent({ enableCallHistory: true })
|
||||
const callHistory = mockAgent.getCallHistory()
|
||||
```
|
||||
|
||||
Call history can also be enabled after construction:
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
mockAgent.enableCallHistory()
|
||||
const callHistory = mockAgent.getCallHistory()
|
||||
```
|
||||
|
||||
## Class: `MockCallHistory`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
Tracks the configuration of intercepted requests as an ordered collection of
|
||||
[`MockCallHistoryLog`][] entries.
|
||||
|
||||
A `MockCallHistory` is iterable. Use it with a `for...of` loop, the spread
|
||||
operator, or any constructor that accepts an iterable:
|
||||
|
||||
```mjs
|
||||
for (const log of mockAgent.getCallHistory()) {
|
||||
// ...
|
||||
}
|
||||
|
||||
const logs = [...mockAgent.getCallHistory()]
|
||||
const logSet = new Set(mockAgent.getCallHistory())
|
||||
```
|
||||
|
||||
### `mockCallHistory.calls()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Returns: {MockCallHistoryLog[]} All recorded logs, in the order the requests
|
||||
were intercepted.
|
||||
|
||||
Returns every recorded [`MockCallHistoryLog`][] as an array.
|
||||
|
||||
```mjs
|
||||
mockAgent.getCallHistory()?.calls()
|
||||
```
|
||||
|
||||
### `mockCallHistory.firstCall()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Returns: {MockCallHistoryLog|undefined} The first recorded log, or `undefined`
|
||||
when no request has been recorded.
|
||||
|
||||
Returns the first recorded [`MockCallHistoryLog`][].
|
||||
|
||||
```mjs
|
||||
mockAgent.getCallHistory()?.firstCall()
|
||||
```
|
||||
|
||||
### `mockCallHistory.lastCall()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Returns: {MockCallHistoryLog|undefined} The last recorded log, or `undefined`
|
||||
when no request has been recorded.
|
||||
|
||||
Returns the last recorded [`MockCallHistoryLog`][].
|
||||
|
||||
```mjs
|
||||
mockAgent.getCallHistory()?.lastCall()
|
||||
```
|
||||
|
||||
### `mockCallHistory.nthCall(position)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* `position` {number} The 1-based position of the log to return. Must be a
|
||||
positive integer.
|
||||
* Returns: {MockCallHistoryLog|undefined} The log at `position`, or `undefined`
|
||||
when no log exists at that position.
|
||||
|
||||
Returns the [`MockCallHistoryLog`][] at the given 1-based `position`. The index
|
||||
is 1-based for readability, so `nthCall(1)` is equivalent to `firstCall()`.
|
||||
|
||||
Throws an [`InvalidArgumentError`][] when `position` is not a number, not an
|
||||
integer, or not positive. Use `firstCall()` or `lastCall()` instead of passing a
|
||||
non-positive value.
|
||||
|
||||
```mjs
|
||||
mockAgent.getCallHistory()?.nthCall(3) // the third recorded log
|
||||
```
|
||||
|
||||
### `mockCallHistory.filterCalls(criteria[, options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* `criteria` {Function|RegExp|Object} The filter to apply.
|
||||
* When a {Function} is given, it is used as the predicate of
|
||||
`Array.prototype.filter` and receives each [`MockCallHistoryLog`][]; logs for
|
||||
which it returns a truthy value are kept.
|
||||
* When a {RegExp} is given, a log is kept when the expression matches its
|
||||
[`mockCallHistoryLog.toString()`][] representation.
|
||||
* When an {Object} is given, each property is a request field to filter on and
|
||||
each value is a [filter parameter](#filter-parameter). An empty object
|
||||
returns every log.
|
||||
* `protocol` {string|RegExp|null|undefined} Filters by request protocol.
|
||||
* `host` {string|RegExp|null|undefined} Filters by request host.
|
||||
* `port` {string|RegExp|null|undefined} Filters by request port.
|
||||
* `origin` {string|RegExp|null|undefined} Filters by request origin.
|
||||
* `path` {string|RegExp|null|undefined} Filters by request path.
|
||||
* `hash` {string|RegExp|null|undefined} Filters by request hash.
|
||||
* `fullUrl` {string|RegExp|null|undefined} Filters by request full URL.
|
||||
* `method` {string|RegExp|null|undefined} Filters by request method.
|
||||
* `options` {Object} Adjusts the filtering behavior. Only applied when `criteria`
|
||||
is an object. (optional)
|
||||
* `operator` {string} How to combine multiple object properties. `'OR'` keeps
|
||||
a log matching any criterion; `'AND'` keeps a log matching every criterion.
|
||||
The value is case-insensitive. **Default:** `'OR'`.
|
||||
* Returns: {MockCallHistoryLog[]} The matching logs. Duplicates produced by an
|
||||
`'OR'` combination are removed.
|
||||
|
||||
A convenience method for applying one or several filters at once. It is a more
|
||||
expressive alternative to the per-field helpers below.
|
||||
|
||||
Throws an [`InvalidArgumentError`][] when `criteria` is not a function, regular
|
||||
expression, or object, or when `options.operator` is neither `'OR'` nor `'AND'`.
|
||||
|
||||
```mjs
|
||||
const callHistory = mockAgent.getCallHistory()
|
||||
|
||||
callHistory?.filterCalls((log) =>
|
||||
log.hash === '#hash' && log.headers?.authorization !== undefined)
|
||||
|
||||
callHistory?.filterCalls(/"errors": "wrong body"/)
|
||||
|
||||
// Logs with a hash containing `my-hash` OR a path equal to `/endpoint`.
|
||||
callHistory?.filterCalls({ hash: /my-hash/, path: '/endpoint' })
|
||||
|
||||
// Logs with a hash containing `my-hash` AND a path equal to `/endpoint`.
|
||||
callHistory?.filterCalls(
|
||||
{ hash: /my-hash/, path: '/endpoint' },
|
||||
{ operator: 'AND' }
|
||||
)
|
||||
```
|
||||
|
||||
### `mockCallHistory.filterCallsByProtocol(criteria)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* `criteria` {string|RegExp|null|undefined} See
|
||||
[filter parameter](#filter-parameter).
|
||||
* Returns: {MockCallHistoryLog[]} The logs whose protocol matches `criteria`.
|
||||
|
||||
Filters the recorded logs by request protocol.
|
||||
|
||||
```mjs
|
||||
mockAgent.getCallHistory()?.filterCallsByProtocol(/https/)
|
||||
mockAgent.getCallHistory()?.filterCallsByProtocol('https:')
|
||||
```
|
||||
|
||||
### `mockCallHistory.filterCallsByHost(criteria)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* `criteria` {string|RegExp|null|undefined} See
|
||||
[filter parameter](#filter-parameter).
|
||||
* Returns: {MockCallHistoryLog[]} The logs whose host matches `criteria`.
|
||||
|
||||
Filters the recorded logs by request host.
|
||||
|
||||
```mjs
|
||||
mockAgent.getCallHistory()?.filterCallsByHost(/localhost/)
|
||||
mockAgent.getCallHistory()?.filterCallsByHost('localhost:3000')
|
||||
```
|
||||
|
||||
### `mockCallHistory.filterCallsByPort(criteria)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* `criteria` {string|RegExp|null|undefined} See
|
||||
[filter parameter](#filter-parameter).
|
||||
* Returns: {MockCallHistoryLog[]} The logs whose port matches `criteria`.
|
||||
|
||||
Filters the recorded logs by request port.
|
||||
|
||||
```mjs
|
||||
mockAgent.getCallHistory()?.filterCallsByPort(/3000/)
|
||||
mockAgent.getCallHistory()?.filterCallsByPort('3000')
|
||||
mockAgent.getCallHistory()?.filterCallsByPort('')
|
||||
```
|
||||
|
||||
### `mockCallHistory.filterCallsByOrigin(criteria)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* `criteria` {string|RegExp|null|undefined} See
|
||||
[filter parameter](#filter-parameter).
|
||||
* Returns: {MockCallHistoryLog[]} The logs whose origin matches `criteria`.
|
||||
|
||||
Filters the recorded logs by request origin.
|
||||
|
||||
```mjs
|
||||
mockAgent.getCallHistory()?.filterCallsByOrigin(/http:\/\/localhost:3000/)
|
||||
mockAgent.getCallHistory()?.filterCallsByOrigin('http://localhost:3000')
|
||||
```
|
||||
|
||||
### `mockCallHistory.filterCallsByPath(criteria)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* `criteria` {string|RegExp|null|undefined} See
|
||||
[filter parameter](#filter-parameter).
|
||||
* Returns: {MockCallHistoryLog[]} The logs whose path matches `criteria`.
|
||||
|
||||
Filters the recorded logs by request path.
|
||||
|
||||
```mjs
|
||||
mockAgent.getCallHistory()?.filterCallsByPath(/api\/v1\/graphql/)
|
||||
mockAgent.getCallHistory()?.filterCallsByPath('/api/v1/graphql')
|
||||
```
|
||||
|
||||
### `mockCallHistory.filterCallsByHash(criteria)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* `criteria` {string|RegExp|null|undefined} See
|
||||
[filter parameter](#filter-parameter).
|
||||
* Returns: {MockCallHistoryLog[]} The logs whose hash matches `criteria`.
|
||||
|
||||
Filters the recorded logs by request hash.
|
||||
|
||||
```mjs
|
||||
mockAgent.getCallHistory()?.filterCallsByHash(/hash/)
|
||||
mockAgent.getCallHistory()?.filterCallsByHash('#hash')
|
||||
```
|
||||
|
||||
### `mockCallHistory.filterCallsByFullUrl(criteria)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* `criteria` {string|RegExp|null|undefined} See
|
||||
[filter parameter](#filter-parameter).
|
||||
* Returns: {MockCallHistoryLog[]} The logs whose full URL matches `criteria`.
|
||||
|
||||
Filters the recorded logs by request full URL. The full URL contains the
|
||||
protocol, host, port, path, query parameters, and hash.
|
||||
|
||||
```mjs
|
||||
mockAgent.getCallHistory()?.filterCallsByFullUrl(/https:\/\/localhost:3000\/\?query=value#hash/)
|
||||
mockAgent.getCallHistory()?.filterCallsByFullUrl('https://localhost:3000/?query=value#hash')
|
||||
```
|
||||
|
||||
### `mockCallHistory.filterCallsByMethod(criteria)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* `criteria` {string|RegExp|null|undefined} See
|
||||
[filter parameter](#filter-parameter).
|
||||
* Returns: {MockCallHistoryLog[]} The logs whose method matches `criteria`.
|
||||
|
||||
Filters the recorded logs by request method.
|
||||
|
||||
```mjs
|
||||
mockAgent.getCallHistory()?.filterCallsByMethod(/POST/)
|
||||
mockAgent.getCallHistory()?.filterCallsByMethod('POST')
|
||||
```
|
||||
|
||||
### `mockCallHistory.clear()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Returns: {undefined}
|
||||
|
||||
Removes every recorded [`MockCallHistoryLog`][]. This is performed
|
||||
automatically when `mockAgent.close()` is called.
|
||||
|
||||
```mjs
|
||||
mockAgent.clearCallHistory()
|
||||
// Equivalent to:
|
||||
mockAgent.getCallHistory()?.clear()
|
||||
```
|
||||
|
||||
### `mockCallHistory[Symbol.iterator]()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Returns: {Generator} A generator yielding each recorded
|
||||
[`MockCallHistoryLog`][] in order.
|
||||
|
||||
Makes a `MockCallHistory` iterable, yielding the same logs returned by
|
||||
`calls()`. This enables `for...of` iteration, the spread operator, and
|
||||
constructing other iterables from a history.
|
||||
|
||||
```mjs
|
||||
const callHistory = mockAgent.getCallHistory()
|
||||
|
||||
for (const log of callHistory) {
|
||||
console.log(log.method, log.fullUrl)
|
||||
}
|
||||
|
||||
const logs = [...callHistory]
|
||||
```
|
||||
|
||||
## Filter parameter
|
||||
|
||||
The per-field `filterCallsBy*` helpers and the object form of `filterCalls()`
|
||||
accept a filter parameter that is matched against the corresponding field of each
|
||||
[`MockCallHistoryLog`][]:
|
||||
|
||||
* A {string} keeps a log only when the field is strictly equal to the value.
|
||||
* `null` keeps a log only when the field is strictly equal to `null`.
|
||||
* `undefined` keeps a log only when the field is strictly equal to `undefined`.
|
||||
* A {RegExp} keeps a log only when the expression matches the field.
|
||||
|
||||
Any other value throws an [`InvalidArgumentError`][].
|
||||
|
||||
[`InvalidArgumentError`]: Errors.md#class-invalidargumenterror
|
||||
[`MockAgent`]: MockAgent.md#class-mockagent
|
||||
[`MockCallHistoryLog`]: MockCallHistoryLog.md#class-mockcallhistorylog
|
||||
[`mockCallHistoryLog.toString()`]: MockCallHistoryLog.md#mockcallhistorylogtostring
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
# MockCallHistoryLog
|
||||
|
||||
<!--introduced_in=v7.5.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/mock/mock-call-history.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A `MockCallHistoryLog` is an immutable snapshot of the configuration of a single
|
||||
request that was intercepted by a [`MockAgent`][]. Each property mirrors a part
|
||||
of the request, with the URL components already parsed by the WHATWG URL parser.
|
||||
|
||||
Instances are not created directly. They are produced by a [`MockCallHistory`][]
|
||||
when call history is enabled on a [`MockAgent`][], and can be retrieved through
|
||||
the call history helpers such as `firstCall()`, `lastCall()`, `nthCall()`,
|
||||
`calls()`, and `filterCalls()`.
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent({ enableCallHistory: true })
|
||||
|
||||
// after some requests have been intercepted:
|
||||
const log = mockAgent.getCallHistory()?.firstCall()
|
||||
```
|
||||
|
||||
## Class: `MockCallHistoryLog`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
Represents the configuration of one intercepted request.
|
||||
|
||||
### `new MockCallHistoryLog(requestInit)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* `requestInit` {DispatchOptions} The dispatch options of the intercepted
|
||||
request. **Default:** `{}`.
|
||||
|
||||
Creates a new `MockCallHistoryLog`. The `body`, `headers`, and `method`
|
||||
properties are copied directly from `requestInit`, while the URL-derived
|
||||
properties (`fullUrl`, `origin`, `path`, `searchParams`, `protocol`, `host`,
|
||||
`port`, and `hash`) are computed from `requestInit.path` and `requestInit.origin`
|
||||
using the WHATWG URL parser.
|
||||
|
||||
This constructor is internal-facing: instances are created by
|
||||
[`MockCallHistory`][] when a request is intercepted. It is documented because the
|
||||
class is part of the public API surface.
|
||||
|
||||
### `mockCallHistoryLog.body`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Type: {string|null|undefined}
|
||||
|
||||
The body of the intercepted request, exactly as supplied to the dispatcher.
|
||||
|
||||
### `mockCallHistoryLog.headers`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Type: {Record<string, string|string[]>|null|undefined}
|
||||
|
||||
The headers of the intercepted request.
|
||||
|
||||
### `mockCallHistoryLog.method`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Type: {string}
|
||||
|
||||
The HTTP method of the intercepted request, for example `'GET'` or `'PUT'`.
|
||||
|
||||
### `mockCallHistoryLog.fullUrl`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Type: {string}
|
||||
|
||||
The full URL of the intercepted request, including protocol, host, path, search
|
||||
parameters, and hash.
|
||||
|
||||
### `mockCallHistoryLog.origin`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Type: {string}
|
||||
|
||||
The origin of the intercepted request, comprising the protocol and host, for
|
||||
example `'https://localhost:4000'`.
|
||||
|
||||
### `mockCallHistoryLog.path`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Type: {string}
|
||||
|
||||
The pathname of the intercepted request. It always starts with `/` and never
|
||||
contains search parameters.
|
||||
|
||||
### `mockCallHistoryLog.searchParams`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Type: {Record<string, string>}
|
||||
|
||||
The search parameters of the intercepted request as a plain object of key/value
|
||||
pairs.
|
||||
|
||||
### `mockCallHistoryLog.protocol`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Type: {string}
|
||||
|
||||
The protocol of the intercepted request, including the trailing colon, for
|
||||
example `'https:'` or `'http:'`.
|
||||
|
||||
### `mockCallHistoryLog.host`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Type: {string}
|
||||
|
||||
The host of the intercepted request, including the port when present.
|
||||
|
||||
### `mockCallHistoryLog.port`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Type: {string}
|
||||
|
||||
The port of the intercepted request. It is an empty string when no explicit port
|
||||
was used, otherwise a string of digits.
|
||||
|
||||
### `mockCallHistoryLog.hash`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Type: {string}
|
||||
|
||||
The hash fragment of the intercepted request. It is an empty string when absent,
|
||||
otherwise a string starting with `#`.
|
||||
|
||||
### `mockCallHistoryLog.toMap()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Returns: {Map} A `Map` whose keys are the property names `protocol`, `host`,
|
||||
`port`, `origin`, `path`, `hash`, `searchParams`, `fullUrl`, `method`, `body`,
|
||||
and `headers`, each mapped to the corresponding value of this log.
|
||||
|
||||
Returns the log as a `Map` of property name to value.
|
||||
|
||||
```mjs
|
||||
mockAgent.getCallHistory()?.firstCall()?.toMap().get('hash')
|
||||
// '#hash'
|
||||
```
|
||||
|
||||
### `mockCallHistoryLog.toString()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.5.0
|
||||
-->
|
||||
|
||||
* Returns: {string} A string representation of the log.
|
||||
|
||||
Returns a string built from every property name and value pair. Each pair is
|
||||
joined with `->` and pairs are separated with `|`. Object values such as
|
||||
`searchParams` and `headers` are serialized with `JSON.stringify()`.
|
||||
|
||||
```mjs
|
||||
mockAgent.getCallHistory()?.firstCall()?.toString()
|
||||
// protocol->https:|host->localhost:4000|port->4000|origin->https://localhost:4000|path->/endpoint|hash->#here|searchParams->{"query":"value"}|fullUrl->https://localhost:4000/endpoint?query=value#here|method->PUT|body->"{ "data": "hello" }"|headers->{"content-type":"application/json"}
|
||||
```
|
||||
|
||||
[`MockAgent`]: MockAgent.md#class-mockagent
|
||||
[`MockCallHistory`]: MockCallHistory.md#class-mockcallhistory
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
# MockClient
|
||||
|
||||
<!--introduced_in=v4.0.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/mock/mock-client.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A `MockClient` is a [`Client`][] that intercepts requests matching registered
|
||||
routes and replies with mocked responses instead of contacting the network.
|
||||
It is created by a [`MockAgent`][] when the agent is configured with a single
|
||||
connection, and it exposes the same interception API as [`MockPool`][].
|
||||
|
||||
A `MockClient` is not constructed directly in most cases; instead it is obtained
|
||||
through [`mockAgent.get(origin)`][] once the agent has `connections` set to `1`.
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
// `connections: 1` makes the agent hand out `MockClient` instances.
|
||||
const mockAgent = new MockAgent({ connections: 1 })
|
||||
const mockClient = mockAgent.get('http://localhost:3000')
|
||||
```
|
||||
|
||||
```cjs
|
||||
const { MockAgent } = require('undici')
|
||||
|
||||
const mockAgent = new MockAgent({ connections: 1 })
|
||||
const mockClient = mockAgent.get('http://localhost:3000')
|
||||
```
|
||||
|
||||
## Class: `MockClient`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Extends: {Client}
|
||||
|
||||
Extends [`Client`][] and implements the `Interceptable` interface, allowing
|
||||
requests made through it to be matched against registered mocks.
|
||||
|
||||
### `new MockClient(origin[, options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `origin` {string} The origin to associate this mock client with. It should
|
||||
only include the protocol, hostname, and port.
|
||||
* `options` {MockClientOptions} Extends the [`Client`][] options.
|
||||
* Returns: {MockClient}
|
||||
|
||||
The `agent` option is required and must implement the `Agent` interface;
|
||||
otherwise an `InvalidArgumentError` is thrown.
|
||||
|
||||
#### Parameter: `MockClientOptions`
|
||||
|
||||
Extends: `ClientOptions`
|
||||
|
||||
* `agent` {MockAgent} The agent to associate this mock client with.
|
||||
* `ignoreTrailingSlash` {boolean} Whether trailing slashes should be ignored
|
||||
when matching the path of intercepted requests. **Default:** `false`.
|
||||
|
||||
### `mockClient.intercept(options)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `options` {MockInterceptor.Options} The matching criteria for the requests to
|
||||
intercept.
|
||||
* `path` {string|RegExp|Function} The path to match against. A function
|
||||
receives the request path as a `string` and returns a `boolean`.
|
||||
* `method` {string|RegExp|Function} The method to match against. A function
|
||||
receives the request method as a `string` and returns a `boolean`.
|
||||
**Default:** `'GET'`.
|
||||
* `body` {string|RegExp|Function} The body to match against. A function
|
||||
receives the request body as a `string` and returns a `boolean`.
|
||||
* `headers` {Object|Function} The headers to match against, as a map of header
|
||||
name to a `string`, `RegExp`, or matching function, or a single function
|
||||
that receives all headers and returns a `boolean`.
|
||||
* `query` {Object} The query parameters to match against.
|
||||
* `ignoreTrailingSlash` {boolean} Whether a trailing slash on `path` is
|
||||
ignored when matching. Inherited from the mock client's
|
||||
`ignoreTrailingSlash` option when not set.
|
||||
* Returns: {MockInterceptor} The interceptor used to define the mocked reply.
|
||||
|
||||
Registers a route on the mock client and returns a [`MockInterceptor`][] for any
|
||||
matching request that uses the same origin as this mock client. The interceptor
|
||||
is then used to define the reply, for example with `reply()` or
|
||||
`replyWithError()`.
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent({ connections: 1 })
|
||||
const mockClient = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockClient.intercept({ path: '/foo', method: 'GET' }).reply(200, 'foo')
|
||||
```
|
||||
|
||||
### `mockClient.cleanMocks()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.11.0
|
||||
-->
|
||||
|
||||
Removes all registered interceptors from the mock client. Pending mocks defined
|
||||
before this call no longer match incoming requests.
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent({ connections: 1 })
|
||||
const mockClient = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockClient.intercept({ path: '/foo' }).reply(200, 'foo')
|
||||
mockClient.cleanMocks()
|
||||
```
|
||||
|
||||
### `mockClient.close()`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Returns: {Promise} Fulfills with `undefined` once the mock client is closed.
|
||||
|
||||
Closes the mock client, gracefully waiting for any enqueued requests to
|
||||
complete, and removes it from the associated [`MockAgent`][].
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent({ connections: 1 })
|
||||
const mockClient = mockAgent.get('http://localhost:3000')
|
||||
|
||||
await mockClient.close()
|
||||
```
|
||||
|
||||
### `mockClient.dispatch(options, handlers)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `options` {DispatchOptions} The request options.
|
||||
* `handlers` {DispatchHandler} The handlers invoked over the request lifecycle.
|
||||
* Returns: {boolean} `false` if the dispatcher is busy and the caller should
|
||||
wait before dispatching further requests, otherwise `true`.
|
||||
|
||||
Dispatches a request, matching it against the registered mocks. This override of
|
||||
[`dispatcher.dispatch(options, handlers)`][] is what drives the mocking
|
||||
behaviour for every higher-level method such as `request`.
|
||||
|
||||
### `mockClient.request(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `options` {DispatchOptions}
|
||||
* `callback` {Function} (optional) Invoked with the response when no `Promise`
|
||||
is requested.
|
||||
* Returns: {Promise} Fulfills with the mocked response when no `callback` is
|
||||
provided.
|
||||
|
||||
Performs a request and resolves it against the registered mocks. Inherited from
|
||||
[`Client`][]; see [`dispatcher.request(options[, callback])`][] for the full
|
||||
parameter and return value documentation.
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent({ connections: 1 })
|
||||
const mockClient = mockAgent.get('http://localhost:3000')
|
||||
mockClient.intercept({ path: '/foo' }).reply(200, 'foo')
|
||||
|
||||
const { statusCode, body } = await mockClient.request({
|
||||
origin: 'http://localhost:3000',
|
||||
path: '/foo',
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
|
||||
for await (const data of body) {
|
||||
console.log('data', data.toString('utf8')) // data foo
|
||||
}
|
||||
```
|
||||
|
||||
[`Client`]: Client.md#class-client
|
||||
[`MockAgent`]: MockAgent.md#class-mockagent
|
||||
[`MockInterceptor`]: MockPool.md#return-mockinterceptor
|
||||
[`MockPool`]: MockPool.md#class-mockpool
|
||||
[`dispatcher.dispatch(options, handlers)`]: Dispatcher.md#dispatcherdispatchoptions-handler
|
||||
[`dispatcher.request(options[, callback])`]: Dispatcher.md#dispatcherrequestoptions-callback
|
||||
[`mockAgent.get(origin)`]: MockAgent.md#mockagentgetorigin
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# MockErrors
|
||||
|
||||
<!--introduced_in=v4.0.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/mock/mock-errors.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
undici raises a dedicated error when a mocked request cannot be matched against
|
||||
any registered interceptor. The mock error classes are exposed through the
|
||||
`mockErrors` namespace of the package:
|
||||
|
||||
```js
|
||||
import { mockErrors } from 'undici'
|
||||
|
||||
if (err instanceof mockErrors.MockNotMatchedError) {
|
||||
// handle an unmatched mocked request
|
||||
}
|
||||
```
|
||||
|
||||
Like every other undici error, the classes in this namespace extend
|
||||
[`UndiciError`][] and carry a stable `code` string, so failures can be
|
||||
distinguished programmatically without relying on the error message.
|
||||
|
||||
## Class: `MockNotMatchedError`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Extends: {UndiciError}
|
||||
|
||||
Thrown when a request issued against a [`MockAgent`][] does not match any of the
|
||||
registered mock dispatches and the agent is configured to disable net connect.
|
||||
|
||||
* `name` {string} Always `'MockNotMatchedError'`.
|
||||
* `code` {string} Always `'UND_MOCK_ERR_MOCK_NOT_MATCHED'`.
|
||||
|
||||
### `new MockNotMatchedError([message])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `message` {string} The error message. **Default:** `'The request does not match any registered mock dispatches'`.
|
||||
|
||||
Creates a new `MockNotMatchedError`. When `message` is omitted, a default
|
||||
message describing the unmatched request is used.
|
||||
|
||||
```js
|
||||
import { mockErrors } from 'undici'
|
||||
|
||||
try {
|
||||
// ... perform a request that no mock matches ...
|
||||
} catch (err) {
|
||||
if (err.code === 'UND_MOCK_ERR_MOCK_NOT_MATCHED') {
|
||||
console.error('No registered mock matched the request')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[`MockAgent`]: MockAgent.md#class-mockagent
|
||||
[`UndiciError`]: Errors.md#class-undicierror
|
||||
+618
@@ -0,0 +1,618 @@
|
||||
# MockPool
|
||||
|
||||
<!--introduced_in=v4.0.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/mock/mock-pool.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A `MockPool` is a [`Pool`][] that intercepts requests matching registered routes
|
||||
and replies with mocked responses instead of contacting the network. It is
|
||||
created by a [`MockAgent`][] and exposes the same interception API as
|
||||
[`MockClient`][].
|
||||
|
||||
A `MockPool` is not constructed directly in most cases; instead it is obtained
|
||||
through [`mockAgent.get(origin)`][]. Once registered as the global dispatcher,
|
||||
any request whose origin matches the pool is matched against the mocks it holds.
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
```
|
||||
|
||||
```cjs
|
||||
const { MockAgent } = require('undici')
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
```
|
||||
|
||||
## Class: `MockPool`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Extends: {Pool}
|
||||
|
||||
Extends [`Pool`][] and implements the `Interceptable` interface, allowing
|
||||
requests made through it to be matched against registered mocks.
|
||||
|
||||
### `new MockPool(origin[, options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `origin` {string} The origin to associate this mock pool with. It should only
|
||||
include the protocol, hostname, and port.
|
||||
* `options` {MockPoolOptions} Extends the [`Pool`][] options.
|
||||
* Returns: {MockPool}
|
||||
|
||||
The `agent` option is required and must implement the `Agent` interface;
|
||||
otherwise an `InvalidArgumentError` is thrown.
|
||||
|
||||
#### Parameter: `MockPoolOptions`
|
||||
|
||||
Extends: `PoolOptions`
|
||||
|
||||
* `agent` {MockAgent} The agent to associate this mock pool with.
|
||||
* `ignoreTrailingSlash` {boolean} Whether trailing slashes should be ignored
|
||||
when matching the path of intercepted requests. **Default:** `false`.
|
||||
|
||||
### `mockPool.intercept(options)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `options` {MockPoolInterceptOptions} The matching criteria for the requests to
|
||||
intercept.
|
||||
* Returns: {MockInterceptor} The interceptor used to define the mocked reply.
|
||||
|
||||
Registers a route on the mock pool and returns a [`MockInterceptor`][] for any
|
||||
matching request that uses the same origin as this mock pool. The interceptor is
|
||||
then used to define the reply, for example with `reply()` or `replyWithError()`.
|
||||
|
||||
Each `intercept()` call is consumed by a single matching request. To match more
|
||||
than one request, call `intercept()` once per expected request, or use
|
||||
`persist()` or `times()` on the returned [`MockScope`][]. When no registered
|
||||
mock matches a request, a real request is attempted unless network connections
|
||||
have been disabled on the [`MockAgent`][], in which case a `MockNotMatchedError`
|
||||
is thrown.
|
||||
|
||||
A request is intercepted only when every defined matcher passes. The accepted
|
||||
matcher forms behave as follows:
|
||||
|
||||
| Matcher type | Condition to pass |
|
||||
| :----------: | ---------------------------- |
|
||||
| `string` | Exact match against the value |
|
||||
| `RegExp` | The expression must match |
|
||||
| `Function` | The function must return `true` |
|
||||
|
||||
#### Parameter: `MockPoolInterceptOptions`
|
||||
|
||||
* `path` {string|RegExp|Function} A matcher for the HTTP request path. The
|
||||
function form has the signature `(path: string) => boolean`. When a `RegExp`
|
||||
or function is used, it matches against the request path including all query
|
||||
parameters in alphabetical order. When a `string` is used, query parameters
|
||||
can instead be supplied through `query`.
|
||||
* `method` {string|RegExp|Function} A matcher for the HTTP request method. The
|
||||
function form has the signature `(method: string) => boolean`.
|
||||
**Default:** `'GET'`.
|
||||
* `body` {string|RegExp|Function} A matcher for the HTTP request body. The
|
||||
function form has the signature `(body: string) => boolean`.
|
||||
* `headers` {Object|Function} A matcher for the HTTP request headers, given
|
||||
either as a map of header name to a `string`, `RegExp`, or
|
||||
`(value: string) => boolean` matcher, or as a single function that receives
|
||||
all headers and returns a `boolean`. When a map is used, the request must
|
||||
match every defined header; extra headers not listed here do not affect
|
||||
matching.
|
||||
* `query` {Object} A matcher for the HTTP request query string parameters. Only
|
||||
applies when a `string` was provided for `path`.
|
||||
* `ignoreTrailingSlash` {boolean} Whether a trailing slash on `path` is ignored
|
||||
when matching. Inherited from the mock pool's `ignoreTrailingSlash` option
|
||||
when not set.
|
||||
|
||||
#### Return: `MockInterceptor`
|
||||
|
||||
The reply behaviour of a matching request is defined through the returned
|
||||
`MockInterceptor`.
|
||||
|
||||
* `reply(statusCode[, data[, responseOptions]])` {Function} Defines the reply
|
||||
for a matching request.
|
||||
* `statusCode` {number} The status code of the mocked reply.
|
||||
* `data` {string|Buffer|Object|Function} The body of the mocked reply.
|
||||
Objects are serialized to JSON; strings and `Buffer`s are sent as-is. The
|
||||
function form has the signature
|
||||
`(opts: MockResponseCallbackOptions) => string | Buffer | Object` and is
|
||||
invoked with the incoming request to compute the reply body.
|
||||
* `responseOptions` {MockResponseOptions} Additional reply options.
|
||||
**Default:** `{}`.
|
||||
* Returns: {MockScope}
|
||||
* `reply(callback)` {Function} Defines the reply for a matching request,
|
||||
computing all reply options dynamically rather than just the body.
|
||||
* `callback` {Function} A `(opts: MockResponseCallbackOptions) =>
|
||||
{ statusCode, data, responseOptions }` function invoked with the incoming
|
||||
request. The callback may be asynchronous; a returned promise is awaited
|
||||
and must resolve to the same shape.
|
||||
* Returns: {MockScope}
|
||||
* `replyWithError(error)` {Function} Defines an error for a matching request to
|
||||
throw.
|
||||
* `error` {Error} The error thrown when a request matches.
|
||||
* Returns: {MockScope}
|
||||
* `defaultReplyHeaders(headers)` {Function} Sets default headers included on
|
||||
every subsequent reply defined on this interceptor, in addition to any headers
|
||||
set on a specific reply.
|
||||
* `headers` {Object} A map of header name to value.
|
||||
* Returns: {MockInterceptor}
|
||||
* `defaultReplyTrailers(trailers)` {Function} Sets default trailers included on
|
||||
every subsequent reply defined on this interceptor, in addition to any
|
||||
trailers set on a specific reply.
|
||||
* `trailers` {Object} A map of trailer name to value.
|
||||
* Returns: {MockInterceptor}
|
||||
* `replyContentLength()` {Function} Sets an automatically calculated
|
||||
`content-length` header on every subsequent reply defined on this interceptor.
|
||||
* Returns: {MockInterceptor}
|
||||
|
||||
By default, `reply()` and `replyWithError()` define the behaviour for the first
|
||||
matching request only; subsequent requests are not affected unless `persist()`
|
||||
or `times()` is used on the returned [`MockScope`][].
|
||||
|
||||
#### Parameter: `MockResponseCallbackOptions`
|
||||
|
||||
The argument passed to the `reply()` data and options callbacks.
|
||||
|
||||
* `path` {string} The path of the intercepted request.
|
||||
* `method` {string} The method of the intercepted request.
|
||||
* `headers` {Object|Headers} The headers of the intercepted request.
|
||||
* `origin` {string} The origin of the intercepted request.
|
||||
* `body` {string|null} The body of the intercepted request.
|
||||
|
||||
#### Parameter: `MockResponseOptions`
|
||||
|
||||
* `headers` {Object} Headers to include on the mocked reply.
|
||||
* `trailers` {Object} Trailers to include on the mocked reply.
|
||||
|
||||
#### Return: `MockScope`
|
||||
|
||||
A `MockScope` is associated with a single [`MockInterceptor`][] and configures
|
||||
how many times the defined reply is used.
|
||||
|
||||
* `delay(waitInMs)` {Function} Delays the associated reply by a set amount of
|
||||
time.
|
||||
* `waitInMs` {number} The delay in milliseconds.
|
||||
* Returns: {MockScope}
|
||||
* `persist()` {Function} Makes the associated reply match indefinitely, so every
|
||||
matching request receives the defined response.
|
||||
* Returns: {MockScope}
|
||||
* `times(repeatTimes)` {Function} Makes the associated reply match a fixed number
|
||||
of times. This is overridden by `persist()`.
|
||||
* `repeatTimes` {number} The number of matching requests the reply applies to.
|
||||
* Returns: {MockScope}
|
||||
|
||||
The following examples show common interception patterns.
|
||||
|
||||
```mjs displayName="Basic mocked request"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
mockPool.intercept({ path: '/foo' }).reply(200, 'foo')
|
||||
|
||||
const { statusCode, body } = await request('http://localhost:3000/foo')
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
|
||||
for await (const data of body) {
|
||||
console.log('data', data.toString('utf8')) // data foo
|
||||
}
|
||||
```
|
||||
|
||||
```mjs displayName="Reply with a data callback"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockPool.intercept({
|
||||
path: '/echo',
|
||||
method: 'GET'
|
||||
}).reply(200, ({ headers }) => ({ message: headers.message }))
|
||||
|
||||
const { statusCode, body } = await request('http://localhost:3000/echo', {
|
||||
headers: { message: 'hello world!' }
|
||||
})
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
|
||||
for await (const data of body) {
|
||||
console.log('data', data.toString('utf8')) // {"message":"hello world!"}
|
||||
}
|
||||
```
|
||||
|
||||
```mjs displayName="Reply with an options callback"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockPool.intercept({
|
||||
path: '/echo',
|
||||
method: 'GET'
|
||||
}).reply(({ headers }) => ({
|
||||
statusCode: 200,
|
||||
data: { message: headers.message }
|
||||
}))
|
||||
|
||||
const { statusCode, body } = await request('http://localhost:3000/echo', {
|
||||
headers: { message: 'hello world!' }
|
||||
})
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
|
||||
for await (const data of body) {
|
||||
console.log('data', data.toString('utf8')) // {"message":"hello world!"}
|
||||
}
|
||||
```
|
||||
|
||||
```mjs displayName="Reply with an asynchronous options callback"
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockPool.intercept({
|
||||
path: '/fixture',
|
||||
method: 'GET'
|
||||
}).reply(async ({ path }) => ({
|
||||
statusCode: 200,
|
||||
data: await readFile(new URL('./fixture.json', import.meta.url))
|
||||
}))
|
||||
|
||||
const { statusCode, body } = await request('http://localhost:3000/fixture')
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
|
||||
for await (const data of body) {
|
||||
console.log('data', data.toString('utf8')) // contents of fixture.json
|
||||
}
|
||||
```
|
||||
|
||||
```mjs displayName="Multiple intercepts"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockPool.intercept({ path: '/foo', method: 'GET' }).reply(200, 'foo')
|
||||
mockPool.intercept({ path: '/hello', method: 'GET' }).reply(200, 'hello')
|
||||
|
||||
const fooResult = await request('http://localhost:3000/foo')
|
||||
for await (const data of fooResult.body) {
|
||||
console.log('data', data.toString('utf8')) // data foo
|
||||
}
|
||||
|
||||
const helloResult = await request('http://localhost:3000/hello')
|
||||
for await (const data of helloResult.body) {
|
||||
console.log('data', data.toString('utf8')) // data hello
|
||||
}
|
||||
```
|
||||
|
||||
```mjs displayName="Match query, body, request headers; reply with headers and trailers"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockPool.intercept({
|
||||
path: '/foo?hello=there&see=ya',
|
||||
method: 'POST',
|
||||
body: 'form1=data1&form2=data2',
|
||||
headers: {
|
||||
'User-Agent': 'undici',
|
||||
Host: 'example.com'
|
||||
}
|
||||
}).reply(200, { foo: 'bar' }, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
trailers: { 'Content-MD5': 'test' }
|
||||
})
|
||||
|
||||
const { statusCode, headers, trailers, body } = await request(
|
||||
'http://localhost:3000/foo?hello=there&see=ya',
|
||||
{
|
||||
method: 'POST',
|
||||
body: 'form1=data1&form2=data2',
|
||||
headers: {
|
||||
foo: 'bar',
|
||||
'User-Agent': 'undici',
|
||||
Host: 'example.com'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
console.log('headers', headers) // { 'content-type': 'application/json' }
|
||||
|
||||
for await (const data of body) {
|
||||
console.log('data', data.toString('utf8')) // {"foo":"bar"}
|
||||
}
|
||||
|
||||
console.log('trailers', trailers) // { 'content-md5': 'test' }
|
||||
```
|
||||
|
||||
```mjs displayName="Different matcher forms"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockPool.intercept({
|
||||
path: '/foo',
|
||||
method: /^GET$/,
|
||||
body: (value) => value === 'form=data',
|
||||
headers: {
|
||||
'User-Agent': 'undici',
|
||||
Host: /^example\.com$/
|
||||
}
|
||||
}).reply(200, 'foo')
|
||||
|
||||
const { statusCode, body } = await request('http://localhost:3000/foo', {
|
||||
method: 'GET',
|
||||
body: 'form=data',
|
||||
headers: {
|
||||
foo: 'bar',
|
||||
'User-Agent': 'undici',
|
||||
Host: 'example.com'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
|
||||
for await (const data of body) {
|
||||
console.log('data', data.toString('utf8')) // data foo
|
||||
}
|
||||
```
|
||||
|
||||
```mjs displayName="Reply with an error"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockPool.intercept({
|
||||
path: '/foo',
|
||||
method: 'GET'
|
||||
}).replyWithError(new Error('kaboom'))
|
||||
|
||||
try {
|
||||
await request('http://localhost:3000/foo', { method: 'GET' })
|
||||
} catch (error) {
|
||||
console.error(error.message) // kaboom
|
||||
}
|
||||
```
|
||||
|
||||
```mjs displayName="Default reply headers"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockPool.intercept({ path: '/foo', method: 'GET' })
|
||||
.defaultReplyHeaders({ foo: 'bar' })
|
||||
.reply(200, 'foo')
|
||||
|
||||
const { headers } = await request('http://localhost:3000/foo')
|
||||
|
||||
console.log('headers', headers) // headers { foo: 'bar' }
|
||||
```
|
||||
|
||||
```mjs displayName="Default reply trailers"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockPool.intercept({ path: '/foo', method: 'GET' })
|
||||
.defaultReplyTrailers({ foo: 'bar' })
|
||||
.reply(200, 'foo')
|
||||
|
||||
const { trailers } = await request('http://localhost:3000/foo')
|
||||
|
||||
console.log('trailers', trailers) // trailers { foo: 'bar' }
|
||||
```
|
||||
|
||||
```mjs displayName="Automatic content-length"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockPool.intercept({ path: '/foo', method: 'GET' })
|
||||
.replyContentLength()
|
||||
.reply(200, 'foo')
|
||||
|
||||
const { headers } = await request('http://localhost:3000/foo')
|
||||
|
||||
console.log('headers', headers) // headers { 'content-length': '3' }
|
||||
```
|
||||
|
||||
```mjs displayName="Persisted reply"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockPool.intercept({ path: '/foo', method: 'GET' }).reply(200, 'foo').persist()
|
||||
|
||||
await request('http://localhost:3000/foo') // Matches and returns the mock
|
||||
await request('http://localhost:3000/foo') // Matches again, indefinitely
|
||||
```
|
||||
|
||||
```mjs displayName="Reply a fixed number of times"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockPool.intercept({ path: '/foo', method: 'GET' }).reply(200, 'foo').times(2)
|
||||
|
||||
await request('http://localhost:3000/foo') // Matches and returns the mock
|
||||
await request('http://localhost:3000/foo') // Matches and returns the mock
|
||||
await request('http://localhost:3000/foo') // No match; a real request is attempted
|
||||
```
|
||||
|
||||
```mjs displayName="Match the path with a function"
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
import querystring from 'node:querystring'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
const matchPath = (requestPath) => {
|
||||
const [pathname, search] = requestPath.split('?')
|
||||
const requestQuery = querystring.parse(search)
|
||||
|
||||
if (!pathname.startsWith('/foo')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return requestQuery.foo === 'bar'
|
||||
}
|
||||
|
||||
mockPool.intercept({ path: matchPath, method: 'GET' }).reply(200, 'foo')
|
||||
|
||||
await request('http://localhost:3000/foo?foo=bar') // Matches and returns the mock
|
||||
```
|
||||
|
||||
### `mockPool.close()`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Returns: {Promise} Fulfills with `undefined` once the mock pool is closed.
|
||||
|
||||
Closes the mock pool, gracefully waiting for any enqueued requests to complete,
|
||||
and removes it from the associated [`MockAgent`][].
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
await mockPool.close()
|
||||
```
|
||||
|
||||
### `mockPool.dispatch(options, handlers)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `options` {DispatchOptions} The request options.
|
||||
* `handlers` {DispatchHandler} The handlers invoked over the request lifecycle.
|
||||
* Returns: {boolean} `false` if the dispatcher is busy and the caller should
|
||||
wait before dispatching further requests, otherwise `true`.
|
||||
|
||||
Dispatches a request, matching it against the registered mocks. This override of
|
||||
[`dispatcher.dispatch(options, handlers)`][] is what drives the mocking
|
||||
behaviour for every higher-level method such as `request`.
|
||||
|
||||
### `mockPool.request(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `options` {DispatchOptions}
|
||||
* `callback` {Function} (optional) Invoked with the response when no `Promise`
|
||||
is requested.
|
||||
* Returns: {Promise} Fulfills with the mocked response when no `callback` is
|
||||
provided.
|
||||
|
||||
Performs a request and resolves it against the registered mocks. Inherited from
|
||||
[`Pool`][]; see [`dispatcher.request(options[, callback])`][] for the full
|
||||
parameter and return value documentation.
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
mockPool.intercept({ path: '/foo', method: 'GET' }).reply(200, 'foo')
|
||||
|
||||
const { statusCode, body } = await mockPool.request({
|
||||
origin: 'http://localhost:3000',
|
||||
path: '/foo',
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
console.log('response received', statusCode) // response received 200
|
||||
|
||||
for await (const data of body) {
|
||||
console.log('data', data.toString('utf8')) // data foo
|
||||
}
|
||||
```
|
||||
|
||||
### `mockPool.cleanMocks()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.11.0
|
||||
-->
|
||||
|
||||
* Returns: {undefined}
|
||||
|
||||
Removes all registered interceptors from the mock pool. Pending mocks defined
|
||||
before this call no longer match incoming requests.
|
||||
|
||||
```mjs
|
||||
import { MockAgent } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
const mockPool = mockAgent.get('http://localhost:3000')
|
||||
|
||||
mockPool.intercept({ path: '/foo' }).reply(200, 'foo')
|
||||
mockPool.cleanMocks()
|
||||
```
|
||||
|
||||
[`MockAgent`]: MockAgent.md#class-mockagent
|
||||
[`MockClient`]: MockClient.md#class-mockclient
|
||||
[`MockInterceptor`]: MockPool.md#return-mockinterceptor
|
||||
[`MockScope`]: MockPool.md#return-mockscope
|
||||
[`Pool`]: Pool.md#class-pool
|
||||
[`dispatcher.dispatch(options, handlers)`]: Dispatcher.md#dispatcherdispatchoptions-handler
|
||||
[`dispatcher.request(options[, callback])`]: Dispatcher.md#dispatcherrequestoptions-callback
|
||||
[`mockAgent.get(origin)`]: MockAgent.md#mockagentgetorigin
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
# Pool
|
||||
|
||||
<!--introduced_in=v1.0.0-->
|
||||
|
||||
<!--type=module-->
|
||||
|
||||
<!-- source_link=lib/dispatcher/pool.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A pool of [`Client`][] instances connected to the same upstream origin. A `Pool`
|
||||
spreads requests across several connections to the same host, which allows more
|
||||
than one request to be in flight at a time without enabling pipelining on a
|
||||
single connection.
|
||||
|
||||
`Pool` extends [`Dispatcher`][]. Each [`Client`][] in the pool is created lazily
|
||||
through a configurable `factory` and shares the same options. Requests are not
|
||||
guaranteed to be dispatched in the order in which they are invoked.
|
||||
|
||||
```mjs
|
||||
import { Pool } from 'undici'
|
||||
|
||||
const pool = new Pool('http://localhost:3000', { connections: 10 })
|
||||
const { statusCode, body } = await pool.request({ path: '/', method: 'GET' })
|
||||
console.log(statusCode, await body.text())
|
||||
await pool.close()
|
||||
```
|
||||
|
||||
## Class: `Pool`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v4.10.4
|
||||
pr-url: https://github.com/nodejs/undici/pull/1114
|
||||
description: Reworked as part of the balanced pool implementation.
|
||||
-->
|
||||
|
||||
* Extends: {Dispatcher}
|
||||
|
||||
### `new Pool(url[, options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `url` {URL|string} The upstream origin. It should only include the protocol,
|
||||
hostname, and port.
|
||||
* `options` {PoolOptions} (optional) Extends {ClientOptions}, so every
|
||||
[`Client`][] option is also accepted and forwarded to each pooled client.
|
||||
* `factory` {Function} Creates the [`Client`][] (or other [`Dispatcher`][])
|
||||
instances used by the pool.
|
||||
**Default:** `(origin, opts) => new Client(origin, opts)`.
|
||||
* `origin` {URL} The parsed upstream origin.
|
||||
* `opts` {Object} The resolved client options.
|
||||
* Returns: {Dispatcher}
|
||||
* `connections` {number|null} The maximum number of [`Client`][] instances the
|
||||
pool may create. When set to `null` the pool creates an unlimited number of
|
||||
clients. **Default:** `null`.
|
||||
* `clientTtl` {number|null} The amount of time, in milliseconds, before an idle
|
||||
[`Client`][] is removed from the pool and closed. When set to `null` clients
|
||||
are never removed based on age. **Default:** `null`.
|
||||
|
||||
Creates a new `Pool` against `url`. If `connections` is provided it must be a
|
||||
finite, non-negative number, otherwise an [`InvalidArgumentError`][] is thrown.
|
||||
The `factory` must be a function, and `connect` (inherited from {ClientOptions})
|
||||
must be a function or an object when provided.
|
||||
|
||||
The connection-related {ClientOptions} (`connect`, `connectTimeout`, `tls`,
|
||||
`maxCachedSessions`, `socketPath`, `autoSelectFamily`,
|
||||
`autoSelectFamilyAttemptTimeout`, `allowH2`, and `useH2c`) are used to build the
|
||||
connector shared by every pooled client.
|
||||
|
||||
> [!NOTE]
|
||||
> `Pool` inherits all {ClientOptions}, including `allowH2` and
|
||||
> `maxConcurrentStreams`. For an h2-capable HTTPS origin, the pool waits for
|
||||
> the first TLS connection to finish ALPN negotiation before opening more
|
||||
> clients. If the server selects h2, concurrent requests share that session up
|
||||
> to `maxConcurrentStreams`. If it selects HTTP/1.1, the pool resumes normal
|
||||
> connection fan-out up to `connections`.
|
||||
|
||||
```mjs
|
||||
import { Pool } from 'undici'
|
||||
|
||||
const pool = new Pool('http://localhost:3000', {
|
||||
connections: 128,
|
||||
pipelining: 1
|
||||
})
|
||||
```
|
||||
|
||||
### `pool.closed`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {boolean}
|
||||
|
||||
`true` after [`pool.close()`][] has been called.
|
||||
|
||||
### `pool.destroyed`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
changes:
|
||||
- version: v2.0.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/384
|
||||
description: Reflects the state set by `pool.close()`.
|
||||
- version: v4.10.4
|
||||
pr-url: https://github.com/nodejs/undici/pull/1114
|
||||
description: Reworked as part of the balanced pool implementation.
|
||||
-->
|
||||
|
||||
* Type: {boolean}
|
||||
|
||||
`true` after [`pool.destroy()`][] has been called, or after [`pool.close()`][]
|
||||
has been called and the pool shutdown has completed.
|
||||
|
||||
### `pool.stats`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* Type: {PoolStats}
|
||||
|
||||
Aggregate statistics for the pool. See [`PoolStats`][].
|
||||
|
||||
### `pool.close([callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
Implements [`dispatcher.close([callback])`][]. Gracefully closes the pool and
|
||||
all of its clients, waiting for in-flight requests to complete. Resolves once
|
||||
every pooled client has closed.
|
||||
|
||||
### `pool.destroy([error[, callback]])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
Implements [`dispatcher.destroy([error[, callback]])`][]. Forcefully destroys
|
||||
the pool and all of its clients, aborting any in-flight requests with `error`.
|
||||
|
||||
### `pool.connect(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
See [`dispatcher.connect(options[, callback])`][]. Performs an HTTP `CONNECT`
|
||||
request through one of the pooled clients.
|
||||
|
||||
### `pool.dispatch(options, handler)`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.3.0
|
||||
changes:
|
||||
- version: v4.10.4
|
||||
pr-url: https://github.com/nodejs/undici/pull/1114
|
||||
description: Reworked as part of the balanced pool implementation.
|
||||
-->
|
||||
|
||||
Implements [`dispatcher.dispatch(options, handler)`][]. Selects an available
|
||||
client - creating a new one through `factory` when none is free and the
|
||||
`connections` limit has not been reached - and dispatches the request to it.
|
||||
|
||||
### `pool.pipeline(options, handler)`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
See [`dispatcher.pipeline(options, handler)`][]. Sends a request and returns a
|
||||
{Duplex} stream that pipes the request body to the response body through
|
||||
`handler`.
|
||||
|
||||
### `pool.request(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
See [`dispatcher.request(options[, callback])`][]. Performs a request through one
|
||||
of the pooled clients.
|
||||
|
||||
```mjs
|
||||
import { Pool } from 'undici'
|
||||
|
||||
const pool = new Pool('http://localhost:3000', { connections: 10 })
|
||||
const { statusCode, headers, body } = await pool.request({
|
||||
path: '/',
|
||||
method: 'GET'
|
||||
})
|
||||
console.log(statusCode, headers)
|
||||
console.log(await body.text())
|
||||
await pool.close()
|
||||
```
|
||||
|
||||
### `pool.stream(options, factory[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
See [`dispatcher.stream(options, factory[, callback])`][]. Performs a request and
|
||||
writes the response body into the {Writable} stream returned by `factory`.
|
||||
|
||||
### `pool.upgrade(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
See [`dispatcher.upgrade(options[, callback])`][]. Upgrades a connection
|
||||
(for example to a WebSocket) through one of the pooled clients.
|
||||
|
||||
### Event: `'connect'`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `origin` {URL}
|
||||
* `targets` {Array} The pool followed by the client that connected,
|
||||
`[pool, ...targets]`.
|
||||
|
||||
Emitted by a pooled client when a socket has connected. See
|
||||
[`Dispatcher` Event: `'connect'`][]. When `clientTtl` is set, the connecting
|
||||
client's time-to-live timer starts on this event.
|
||||
|
||||
### Event: `'disconnect'`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `origin` {URL}
|
||||
* `targets` {Array} The pool followed by the client that disconnected,
|
||||
`[pool, ...targets]`.
|
||||
* `error` {Error}
|
||||
|
||||
Emitted by a pooled client when a socket has disconnected. See
|
||||
[`Dispatcher` Event: `'disconnect'`][].
|
||||
|
||||
### Event: `'drain'`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.0.0
|
||||
-->
|
||||
|
||||
* `origin` {URL}
|
||||
* `targets` {Array} The pool followed by the client that drained,
|
||||
`[pool, ...targets]`.
|
||||
|
||||
Emitted when a pooled client is no longer busy and can accept more requests. See
|
||||
[`Dispatcher` Event: `'drain'`][].
|
||||
|
||||
[`Client`]: Client.md#class-client
|
||||
[`Dispatcher` Event: `'connect'`]: Dispatcher.md#event-connect
|
||||
[`Dispatcher` Event: `'disconnect'`]: Dispatcher.md#event-disconnect
|
||||
[`Dispatcher` Event: `'drain'`]: Dispatcher.md#event-drain
|
||||
[`Dispatcher`]: Dispatcher.md#class-dispatcher
|
||||
[`InvalidArgumentError`]: Errors.md#class-invalidargumenterror
|
||||
[`PoolStats`]: PoolStats.md
|
||||
[`dispatcher.close([callback])`]: Dispatcher.md#dispatcherclosecallback
|
||||
[`dispatcher.connect(options[, callback])`]: Dispatcher.md#dispatcherconnectoptions-callback
|
||||
[`dispatcher.destroy([error[, callback]])`]: Dispatcher.md#dispatcherdestroyerror-callback
|
||||
[`dispatcher.dispatch(options, handler)`]: Dispatcher.md#dispatcherdispatchoptions-handler
|
||||
[`dispatcher.pipeline(options, handler)`]: Dispatcher.md#dispatcherpipelineoptions-handler
|
||||
[`dispatcher.request(options[, callback])`]: Dispatcher.md#dispatcherrequestoptions-callback
|
||||
[`dispatcher.stream(options, factory[, callback])`]: Dispatcher.md#dispatcherstreamoptions-factory-callback
|
||||
[`dispatcher.upgrade(options[, callback])`]: Dispatcher.md#dispatcherupgradeoptions-callback
|
||||
[`pool.close()`]: #poolclosecallback
|
||||
[`pool.destroy()`]: #pooldestroyerror-callback
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
# PoolStats
|
||||
|
||||
<!--introduced_in=v4.15.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/util/stats.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
`PoolStats` exposes a read-only snapshot of the aggregate connection and request
|
||||
counters for a [`Pool`][] or [`BalancedPool`][]. An instance is created lazily
|
||||
each time the `pool.stats` getter is accessed, so the values reflect the state of
|
||||
the pool at the moment of access.
|
||||
|
||||
`PoolStats` is not constructed directly in normal use; obtain an instance from
|
||||
the `stats` property of a pool:
|
||||
|
||||
```mjs
|
||||
import { Pool } from 'undici'
|
||||
|
||||
const pool = new Pool('http://localhost:3000')
|
||||
const { stats } = pool
|
||||
|
||||
console.log(stats.connected, stats.running, stats.size)
|
||||
```
|
||||
|
||||
## Class: `PoolStats`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.15.0
|
||||
changes:
|
||||
- version: v7.9.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4157
|
||||
description: Pool and client stats are exposed through `Agent`.
|
||||
-->
|
||||
|
||||
Each property is a plain number computed from the pool's internal state when the
|
||||
instance is created. The instance is a snapshot; read `pool.stats` again to
|
||||
observe later changes.
|
||||
|
||||
### `new PoolStats(pool)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.15.0
|
||||
changes:
|
||||
- version: v7.9.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4157
|
||||
description: Pool and client stats are exposed through `Agent`.
|
||||
-->
|
||||
|
||||
* `pool` {Pool|BalancedPool} The pool to read counters from.
|
||||
|
||||
Creates a new `PoolStats` instance from the current state of `pool`. Prefer the
|
||||
`pool.stats` getter, which returns a fresh `PoolStats` on each access.
|
||||
|
||||
### `poolStats.connected`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.15.0
|
||||
changes:
|
||||
- version: v7.9.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4157
|
||||
description: Pool and client stats are exposed through `Agent`.
|
||||
-->
|
||||
|
||||
* Type: {number}
|
||||
|
||||
Number of open socket connections in this pool.
|
||||
|
||||
### `poolStats.free`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.15.0
|
||||
changes:
|
||||
- version: v7.9.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4157
|
||||
description: Pool and client stats are exposed through `Agent`.
|
||||
-->
|
||||
|
||||
* Type: {number}
|
||||
|
||||
Number of open socket connections in this pool that do not have an active
|
||||
request.
|
||||
|
||||
### `poolStats.pending`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.15.0
|
||||
changes:
|
||||
- version: v7.9.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4157
|
||||
description: Pool and client stats are exposed through `Agent`.
|
||||
-->
|
||||
|
||||
* Type: {number}
|
||||
|
||||
Number of pending requests across all clients in this pool.
|
||||
|
||||
### `poolStats.queued`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.15.0
|
||||
changes:
|
||||
- version: v7.9.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4157
|
||||
description: Pool and client stats are exposed through `Agent`.
|
||||
-->
|
||||
|
||||
* Type: {number}
|
||||
|
||||
Number of queued requests across all clients in this pool.
|
||||
|
||||
### `poolStats.running`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.15.0
|
||||
changes:
|
||||
- version: v7.9.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4157
|
||||
description: Pool and client stats are exposed through `Agent`.
|
||||
-->
|
||||
|
||||
* Type: {number}
|
||||
|
||||
Number of currently active requests across all clients in this pool.
|
||||
|
||||
### `poolStats.size`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.15.0
|
||||
changes:
|
||||
- version: v7.9.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4157
|
||||
description: Pool and client stats are exposed through `Agent`.
|
||||
-->
|
||||
|
||||
* Type: {number}
|
||||
|
||||
Number of active, pending, or queued requests across all clients in this pool.
|
||||
|
||||
[`BalancedPool`]: BalancedPool.md#class-balancedpool
|
||||
[`Pool`]: Pool.md#class-pool
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
# ProxyAgent
|
||||
|
||||
<!--introduced_in=v4.8.2-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/dispatcher/proxy-agent.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A `ProxyAgent` routes every request through an HTTP, HTTPS, or SOCKS5 proxy
|
||||
server. It implements the [`Agent`][] API, so it can be used wherever a
|
||||
dispatcher is accepted, for example with [`request`][] or [`fetch`][], or
|
||||
installed globally with [`setGlobalDispatcher`][].
|
||||
|
||||
For secure (`https:`) endpoints the proxy connection is established through an
|
||||
HTTP `CONNECT` tunnel. SOCKS5 proxies (`socks5:` or `socks:` URIs) are handled
|
||||
by delegating to a [`Socks5ProxyAgent`][].
|
||||
|
||||
```mjs
|
||||
import { ProxyAgent, setGlobalDispatcher } from 'undici'
|
||||
|
||||
const proxyAgent = new ProxyAgent('http://localhost:8000')
|
||||
setGlobalDispatcher(proxyAgent)
|
||||
```
|
||||
|
||||
## Class: `ProxyAgent`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.2
|
||||
-->
|
||||
|
||||
* Extends: {Dispatcher}
|
||||
|
||||
`ProxyAgent` keeps an internal [`Agent`][] that creates per-origin dispatchers
|
||||
on demand. The proxy connection is set up lazily when the first request to an
|
||||
origin is dispatched, then reused for subsequent requests to the same origin.
|
||||
|
||||
### `new ProxyAgent(options)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.2
|
||||
-->
|
||||
|
||||
* `options` {ProxyAgentOptions|string|URL} The proxy URI, or an options object.
|
||||
Required. When a `string` or {URL} is passed it is used as the proxy `uri`.
|
||||
The options object extends {AgentOptions} (omitting `connect`).
|
||||
* `uri` {string} The URI of the proxy server. Required when `options` is an
|
||||
object. Parsed into a {URL} according to the
|
||||
[WHATWG URL Specification][].
|
||||
* `token` {string} A pre-formatted `proxy-authorization` header value sent to
|
||||
the proxy on every request, for example `` `Bearer ${apiKey}` ``.
|
||||
* `auth` {string} A base64-encoded `username:password` credential sent as a
|
||||
`Basic` `proxy-authorization` header. Cannot be combined with `token`.
|
||||
* `headers` {Object} Additional headers sent to the proxy server on the
|
||||
`CONNECT` request (or on every request for an untunneled HTTP proxy).
|
||||
**Default:** `{}`.
|
||||
* `requestTls` {BuildOptions} TLS options for the connection to the request
|
||||
endpoint, passed to the connector builder.
|
||||
* `proxyTls` {BuildOptions} TLS options for the connection to the proxy
|
||||
server, passed to the connector builder.
|
||||
* `clientFactory` {Function} Builds the {Dispatcher} used to talk to the proxy
|
||||
server itself. **Default:** `(origin, opts) => new Pool(origin, opts)`.
|
||||
* `origin` {URL} The proxy origin.
|
||||
* `opts` {Object} The resolved options for the dispatcher.
|
||||
* Returns: {Dispatcher}
|
||||
* `proxyTunnel` {boolean} Forces tunneling through the proxy. By default,
|
||||
Undici detects tunneling based on the request protocol. If the target
|
||||
endpoint uses HTTPS, Undici establishes a `CONNECT` tunnel through the proxy
|
||||
(after the TLS handshake to the proxy itself when the proxy URL is HTTPS).
|
||||
If the target endpoint uses plain HTTP, Undici forwards the request to the
|
||||
proxy using an HTTP/1.1 absolute-form request target (over TLS when the
|
||||
proxy URL is HTTPS), as required by
|
||||
[RFC 9112 §3.2.2](https://www.rfc-editor.org/rfc/rfc9112.html#name-absolute-form).
|
||||
This non-tunneled forwarding path does not negotiate HTTP/2 with the proxy.
|
||||
Set `proxyTunnel` to `true` to force tunneling for plain HTTP requests as
|
||||
well. Currently, there is no way to facilitate HTTP/1.1 IP tunneling as
|
||||
described in
|
||||
[RFC 9484](https://www.rfc-editor.org/rfc/rfc9484.html#name-http-11-request).
|
||||
|
||||
Throws an {InvalidArgumentError} when no proxy URI is provided, when
|
||||
`clientFactory` is not a function, or when both `auth` and `token` are supplied.
|
||||
|
||||
The `proxy-authorization` header is derived, in order of precedence, from
|
||||
`token`, then `auth`, then the `username` and `password` embedded in the proxy
|
||||
`uri`. Credentials in the URI are URL-decoded before being base64-encoded.
|
||||
|
||||
> [!NOTE]
|
||||
> Unless {AgentOptions} `connections` is set to `0`, the non-standard
|
||||
> `proxy-connection: keep-alive` header is added to the tunnel `CONNECT`
|
||||
> request. This includes the default case where `connections` is unset.
|
||||
|
||||
The `auth` option is deprecated; use `token` instead.
|
||||
|
||||
```mjs
|
||||
import { ProxyAgent } from 'undici'
|
||||
|
||||
// A string or URL is treated as the proxy uri.
|
||||
const a = new ProxyAgent('http://localhost:8000')
|
||||
const b = new ProxyAgent(new URL('http://localhost:8000'))
|
||||
|
||||
// An options object requires a `uri`.
|
||||
const c = new ProxyAgent({ uri: 'http://localhost:8000' })
|
||||
|
||||
// With proxy TLS settings.
|
||||
const d = new ProxyAgent({
|
||||
uri: 'https://secure.proxy.server',
|
||||
proxyTls: {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
#### Example - proxy request with the global dispatcher
|
||||
|
||||
```mjs
|
||||
import { setGlobalDispatcher, request, ProxyAgent } from 'undici'
|
||||
|
||||
const proxyAgent = new ProxyAgent('http://localhost:8000')
|
||||
setGlobalDispatcher(proxyAgent)
|
||||
|
||||
const { statusCode, body } = await request('http://localhost:3000/foo')
|
||||
|
||||
console.log('response received', statusCode)
|
||||
console.log('data', await body.text())
|
||||
```
|
||||
|
||||
#### Example - proxy request with a local dispatcher
|
||||
|
||||
```mjs
|
||||
import { ProxyAgent, request } from 'undici'
|
||||
|
||||
const proxyAgent = new ProxyAgent('http://localhost:8000')
|
||||
|
||||
const { statusCode, body } = await request('http://localhost:3000/foo', {
|
||||
dispatcher: proxyAgent,
|
||||
})
|
||||
|
||||
console.log('response received', statusCode)
|
||||
console.log('data', await body.text())
|
||||
```
|
||||
|
||||
#### Example - proxy request with authentication
|
||||
|
||||
```mjs
|
||||
import { setGlobalDispatcher, request, ProxyAgent } from 'undici'
|
||||
|
||||
const proxyAgent = new ProxyAgent({
|
||||
uri: 'http://localhost:8000',
|
||||
token: `Basic ${Buffer.from('username:password').toString('base64')}`,
|
||||
})
|
||||
setGlobalDispatcher(proxyAgent)
|
||||
|
||||
const { statusCode } = await request('http://localhost:3000/foo')
|
||||
|
||||
console.log('response received', statusCode)
|
||||
```
|
||||
|
||||
### `proxyAgent.dispatch(options, handler)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.2
|
||||
-->
|
||||
|
||||
* `options` {AgentDispatchOptions} Extends {DispatchOptions}.
|
||||
* `origin` {string|URL} The origin to dispatch the request against. Required.
|
||||
* `handler` {DispatchHandler}
|
||||
* Returns: {boolean} `false` if the dispatcher is busy and the caller should
|
||||
wait for the [`'drain'`][] event before dispatching again.
|
||||
|
||||
Dispatches the request through the proxy. Throws an {InvalidArgumentError} if
|
||||
the request headers contain a `proxy-authorization` header, which must instead
|
||||
be supplied through the constructor's `token` or `auth` option. A `host` header
|
||||
is derived from `options.origin` when one is not already present. Implements
|
||||
[`Dispatcher.dispatch()`][].
|
||||
|
||||
### `proxyAgent.close()`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.8.2
|
||||
changes:
|
||||
- version: v7.10.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/4180
|
||||
description: Untunneled HTTP-to-HTTP proxy connections match `curl` behavior.
|
||||
-->
|
||||
|
||||
* Returns: {Promise<void>}
|
||||
|
||||
Closes the proxy agent and waits for its internal agent and proxy client (when
|
||||
present) to close before resolving. Implements [`Dispatcher.close()`][].
|
||||
|
||||
```mjs
|
||||
import { ProxyAgent, setGlobalDispatcher } from 'undici'
|
||||
|
||||
const proxyAgent = new ProxyAgent('http://localhost:8000')
|
||||
setGlobalDispatcher(proxyAgent)
|
||||
|
||||
await proxyAgent.close()
|
||||
```
|
||||
|
||||
### `proxyAgent.request(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.0.0
|
||||
-->
|
||||
|
||||
See [`Dispatcher.request()`][]. The request is routed through the proxy.
|
||||
|
||||
#### Example - proxy request with `fetch`
|
||||
|
||||
```mjs
|
||||
import { ProxyAgent, fetch } from 'undici'
|
||||
|
||||
const proxyAgent = new ProxyAgent('http://localhost:8000')
|
||||
|
||||
const response = await fetch('http://localhost:3000/foo', {
|
||||
dispatcher: proxyAgent,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
console.log('response status', response.status)
|
||||
console.log('response data', await response.text())
|
||||
```
|
||||
|
||||
#### Example - HTTPS tunneling
|
||||
|
||||
```mjs
|
||||
import { ProxyAgent, fetch } from 'undici'
|
||||
|
||||
const proxyAgent = new ProxyAgent('https://secure.proxy.server')
|
||||
|
||||
const response = await fetch('https://secure.endpoint.com/api/data', {
|
||||
dispatcher: proxyAgent,
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
console.log('response status', response.status)
|
||||
console.log('response data', await response.json())
|
||||
```
|
||||
|
||||
[WHATWG URL Specification]: https://url.spec.whatwg.org
|
||||
[`'drain'`]: Dispatcher.md#event-drain
|
||||
[`Agent`]: Agent.md#class-agent
|
||||
[`Dispatcher.close()`]: Dispatcher.md#dispatcherclosecallback-promise
|
||||
[`Dispatcher.dispatch()`]: Dispatcher.md#dispatcherdispatchoptions-handler
|
||||
[`Dispatcher.request()`]: Dispatcher.md#dispatcherrequestoptions-callback
|
||||
[`Socks5ProxyAgent`]: Socks5ProxyAgent.md#class-socks5proxyagent
|
||||
[`fetch`]: Fetch.md
|
||||
[`request`]: Dispatcher.md#dispatcherrequestoptions-callback
|
||||
[`setGlobalDispatcher`]: Dispatcher.md#setglobaldispatcherdispatcher
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
# RedirectHandler
|
||||
|
||||
<!--introduced_in=v4.0.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/handler/redirect-handler.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A dispatch handler that follows HTTP redirects on behalf of another handler. It
|
||||
wraps a downstream `handler` and a `dispatch` function: when a redirectable
|
||||
response is received it transparently re-dispatches the request to the new
|
||||
location, forwarding the lifecycle callbacks to the wrapped `handler` only once a
|
||||
final (non-redirect) response is reached.
|
||||
|
||||
Redirects are followed for the `300`, `301`, `302`, `303`, `307`, and `308`
|
||||
status codes, up to `maxRedirections` hops. Following the relevant HTTP
|
||||
specifications, the method is downgraded to `GET` for `301`/`302` `POST`
|
||||
responses and for `303` responses with any method other than `HEAD`, request
|
||||
bodies that have already been consumed are not replayed, and headers that refer
|
||||
to the original URL (such as `host`) are stripped on each hop, with additional
|
||||
credential headers removed on cross-origin redirects.
|
||||
|
||||
```mjs
|
||||
import { RedirectHandler } from 'undici'
|
||||
```
|
||||
|
||||
```cjs
|
||||
const { RedirectHandler } = require('undici')
|
||||
```
|
||||
|
||||
Most users do not construct `RedirectHandler` directly. The `maxRedirections`
|
||||
option on [`Dispatcher`][] methods and the higher-level clients use it
|
||||
internally.
|
||||
|
||||
## Class: `RedirectHandler`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Extends: {DispatchHandler}
|
||||
|
||||
Implements the [`DispatchHandler`][] interface, delegating every callback to the
|
||||
wrapped `handler` while intercepting redirect responses.
|
||||
|
||||
### `new RedirectHandler(dispatch, maxRedirections, opts, handler)`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* `dispatch` {Function} The dispatch function used to issue the original request
|
||||
and every subsequent redirect. Called as `dispatch(options, handler)`.
|
||||
* `maxRedirections` {number|null} The maximum number of redirects to follow. Must
|
||||
be a non-negative integer or `null`. When `null` or `0`, no redirects are
|
||||
followed and redirect responses are passed through to the wrapped `handler`.
|
||||
* `opts` {DispatchOptions} The dispatch options for the request. In addition to
|
||||
the standard dispatch options, the following redirect-specific fields are
|
||||
recognized:
|
||||
* `throwOnMaxRedirect` {boolean} When `true`, an error is thrown once
|
||||
`maxRedirections` is reached instead of returning the last redirect response.
|
||||
**Default:** `false`.
|
||||
* `stripHeadersOnRedirect` {string[]} Header names to remove from the request on
|
||||
every redirect hop. **Default:** `null`.
|
||||
* `stripHeadersOnCrossOriginRedirect` {string[]} Additional header names to
|
||||
remove from the request when a redirect points to a different origin.
|
||||
**Default:** `null`.
|
||||
* `handler` {DispatchHandler} The downstream handler that receives the lifecycle
|
||||
callbacks for the final response.
|
||||
|
||||
Throws an [`InvalidArgumentError`][] when `maxRedirections` is not a non-negative
|
||||
integer, when `throwOnMaxRedirect` is not a boolean, or when either
|
||||
`stripHeadersOnRedirect` or `stripHeadersOnCrossOriginRedirect` is not an array of
|
||||
header-name strings.
|
||||
|
||||
```mjs
|
||||
import { Client, RedirectHandler } from 'undici'
|
||||
|
||||
const client = new Client('http://example.com')
|
||||
const dispatch = client.dispatch.bind(client)
|
||||
|
||||
const handler = new RedirectHandler(dispatch, 5, {
|
||||
method: 'GET',
|
||||
path: '/',
|
||||
origin: 'http://example.com',
|
||||
}, {
|
||||
onRequestStart () {},
|
||||
onResponseStart (controller, statusCode, headers) {
|
||||
console.log(statusCode)
|
||||
},
|
||||
onResponseData (controller, chunk) {},
|
||||
onResponseEnd () {},
|
||||
})
|
||||
|
||||
dispatch({ method: 'GET', path: '/', origin: 'http://example.com' }, handler)
|
||||
```
|
||||
|
||||
### Static method: `RedirectHandler.buildDispatch(dispatcher, maxRedirections)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `dispatcher` {Dispatcher} The dispatcher whose `dispatch` method is wrapped.
|
||||
* `maxRedirections` {number|null} The maximum number of redirects to follow. Must
|
||||
be a non-negative integer or `null`.
|
||||
* Returns: {Function} A `dispatch(options, handler)` function that wraps each call
|
||||
in a `RedirectHandler` before delegating to `dispatcher`.
|
||||
|
||||
Builds a redirect-aware dispatch function from an existing dispatcher. The
|
||||
returned function has the same shape as `dispatcher.dispatch` but follows
|
||||
redirects automatically. Throws an [`InvalidArgumentError`][] when
|
||||
`maxRedirections` is not a non-negative integer.
|
||||
|
||||
```mjs
|
||||
import { Client, RedirectHandler } from 'undici'
|
||||
|
||||
const client = new Client('http://example.com')
|
||||
const dispatch = RedirectHandler.buildDispatch(client, 5)
|
||||
|
||||
dispatch({ method: 'GET', path: '/', origin: 'http://example.com' }, {
|
||||
onRequestStart () {},
|
||||
onResponseStart (controller, statusCode) {
|
||||
console.log(statusCode)
|
||||
},
|
||||
onResponseData () {},
|
||||
onResponseEnd () {},
|
||||
})
|
||||
```
|
||||
|
||||
### `redirectHandler.onRequestStart(controller, context)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `controller` {DispatchController} The controller for the in-flight request.
|
||||
* `context` {Object} The dispatch context.
|
||||
|
||||
Forwards the request-start event to the wrapped `handler`, augmenting `context`
|
||||
with the current redirect `history`.
|
||||
|
||||
### `redirectHandler.onRequestUpgrade(controller, statusCode, headers, socket)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `controller` {DispatchController} The controller for the in-flight request.
|
||||
* `statusCode` {number} The HTTP status code of the upgrade response.
|
||||
* `headers` {Object} The response headers.
|
||||
* `socket` {Duplex} The upgraded socket.
|
||||
|
||||
Forwards a connection upgrade to the wrapped `handler`.
|
||||
|
||||
### `redirectHandler.onResponseStart(controller, statusCode, headers, statusMessage)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `controller` {DispatchController} The controller for the in-flight request.
|
||||
* `statusCode` {number} The HTTP status code of the response.
|
||||
* `headers` {Object} The response headers.
|
||||
* `statusMessage` {string} The HTTP status message.
|
||||
|
||||
Inspects the response headers to decide whether to follow a redirect. When the
|
||||
status code is redirectable, the `maxRedirections` limit has not been reached, and
|
||||
the request body has not been consumed, the `location` property is set, the method
|
||||
and headers are adjusted for the next hop, and the response is not forwarded.
|
||||
Otherwise the event is forwarded to the wrapped `handler`.
|
||||
|
||||
Throws when `throwOnMaxRedirect` is `true` and the number of recorded redirects
|
||||
has reached `maxRedirections`, and throws an [`InvalidArgumentError`][] if a
|
||||
redirect loop is detected (for example when a [`Client`][] or [`Pool`][] is used
|
||||
for a cross-origin redirect; use an [`Agent`][] instead).
|
||||
|
||||
### `redirectHandler.onResponseData(controller, chunk)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `controller` {DispatchController} The controller for the in-flight request.
|
||||
* `chunk` {Buffer} A chunk of the response body.
|
||||
|
||||
Forwards body data to the wrapped `handler`. While a redirect is pending
|
||||
(`location` is set), the `3xx` response body is ignored.
|
||||
|
||||
### `redirectHandler.onResponseEnd(controller, trailers)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `controller` {DispatchController} The controller for the in-flight request.
|
||||
* `trailers` {Object} The response trailers.
|
||||
|
||||
Completes the response. When a redirect is pending, the request is re-dispatched
|
||||
to the new `location`; otherwise the end event is forwarded to the wrapped
|
||||
`handler`.
|
||||
|
||||
### `redirectHandler.onResponseError(controller, error)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
* `controller` {DispatchController} The controller for the in-flight request.
|
||||
* `error` {Error} The error that occurred.
|
||||
|
||||
Forwards the error to the wrapped `handler`.
|
||||
|
||||
### `redirectHandler.location`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Type: {string|null}
|
||||
|
||||
The `Location` header value of the redirect currently being followed, or `null`
|
||||
when no redirect is pending and the response is being passed through to the
|
||||
wrapped `handler`.
|
||||
|
||||
### `redirectHandler.opts`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.12.0
|
||||
-->
|
||||
|
||||
* Type: {DispatchOptions}
|
||||
|
||||
A copy of the dispatch options for the next request. It excludes the redirect
|
||||
control fields (`maxRedirections`, `stripHeadersOnRedirect`, and
|
||||
`stripHeadersOnCrossOriginRedirect`) and is mutated on each hop to reflect the
|
||||
updated method, path, origin, and headers.
|
||||
|
||||
### `redirectHandler.maxRedirections`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Type: {number|null}
|
||||
|
||||
The maximum number of redirects this handler will follow.
|
||||
|
||||
### `redirectHandler.handler`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Type: {DispatchHandler}
|
||||
|
||||
The wrapped downstream handler that receives the lifecycle callbacks for the final
|
||||
response.
|
||||
|
||||
### `redirectHandler.history`
|
||||
|
||||
<!-- YAML
|
||||
added: v4.0.0
|
||||
-->
|
||||
|
||||
* Type: {URL[]}
|
||||
|
||||
The ordered list of URLs visited while following redirects. It is used to enforce
|
||||
`maxRedirections` and to detect redirect loops.
|
||||
|
||||
[`Agent`]: Agent.md#class-agent
|
||||
[`Client`]: Client.md#class-client
|
||||
[`Dispatcher`]: Dispatcher.md#class-dispatcher
|
||||
[`DispatchHandler`]: Dispatcher.md#parameter-dispatchhandler
|
||||
[`InvalidArgumentError`]: Errors.md#class-invalidargumenterror
|
||||
[`Pool`]: Pool.md#class-pool
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
# RetryAgent
|
||||
|
||||
<!--introduced_in=v6.7.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/dispatcher/retry-agent.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A [`Dispatcher`][] that automatically retries failed requests by wrapping
|
||||
another dispatcher with the retry logic of [`RetryHandler`][]. Requests that fail
|
||||
with a retryable status code or transport error are re-dispatched according to
|
||||
the configured retry policy.
|
||||
|
||||
```mjs
|
||||
import { RetryAgent } from 'undici'
|
||||
```
|
||||
|
||||
## Class: `RetryAgent`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.7.0
|
||||
-->
|
||||
|
||||
* Extends: {Dispatcher}
|
||||
|
||||
Wraps an existing dispatcher and applies retry behaviour to every request it
|
||||
dispatches. Lifecycle methods such as [`retryAgent.close()`][] and
|
||||
[`retryAgent.destroy()`][] are forwarded to the wrapped dispatcher.
|
||||
|
||||
### `new RetryAgent(dispatcher[, options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.7.0
|
||||
-->
|
||||
|
||||
* `dispatcher` {Dispatcher} The dispatcher to wrap and add retry behaviour to.
|
||||
* `options` {RetryOptions} (optional)
|
||||
* `throwOnError` {boolean} When `true`, an error is thrown on the final retry
|
||||
attempt, preventing the following handlers from being called and destroying
|
||||
the socket. Disable it when you need the response body for error responses
|
||||
or when you provide a custom error handler. **Default:** `true`.
|
||||
* `retry` {Function} Callback invoked on every retry iteration. It receives the
|
||||
error, the current retry state, and a callback. Pass an error to the callback
|
||||
to stop retrying, or call it with no argument to perform the retry.
|
||||
* `err` {Error} The error that caused the retry attempt.
|
||||
* `context` {RetryContext} The current retry context.
|
||||
* `state` {RetryState} The mutable retry state.
|
||||
* `counter` {number} The number of retries performed so far.
|
||||
* `opts` {Object} The options passed to the retry handler, combining the
|
||||
dispatch options with `retryOptions`.
|
||||
* `callback` {Function} Called to continue. Pass an `Error` to stop retrying,
|
||||
or `null`/`undefined` to perform the next retry.
|
||||
* `maxRetries` {number} Maximum number of retries to allow. **Default:** `5`.
|
||||
* `maxTimeout` {number} Maximum number of milliseconds to wait between retries.
|
||||
**Default:** `30000` (30 seconds).
|
||||
* `minTimeout` {number} Initial number of milliseconds to wait before the first
|
||||
retry. **Default:** `500` (half a second).
|
||||
* `timeoutFactor` {number} Factor by which the timeout is multiplied between
|
||||
retries. **Default:** `2`.
|
||||
* `retryAfter` {boolean} When `true`, the delay between retries is inferred from
|
||||
the `Retry-After` response header when present. **Default:** `true`.
|
||||
* `methods` {string[]} HTTP methods that are eligible for retry.
|
||||
**Default:** `['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE']`.
|
||||
* `statusCodes` {number[]} HTTP status codes that trigger a retry.
|
||||
**Default:** `[500, 502, 503, 504, 429]`.
|
||||
* `errorCodes` {string[]} Transport error codes that trigger a retry.
|
||||
**Default:** `['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN', 'ENETUNREACH', 'EHOSTDOWN', 'EHOSTUNREACH', 'EPIPE', 'UND_ERR_SOCKET']`.
|
||||
|
||||
Creates a new `RetryAgent` that wraps `dispatcher`. The `options` are stored and
|
||||
used to construct a [`RetryHandler`][] for each dispatched request.
|
||||
|
||||
```mjs
|
||||
import { Agent, RetryAgent } from 'undici'
|
||||
|
||||
const agent = new RetryAgent(new Agent(), {
|
||||
maxRetries: 3,
|
||||
minTimeout: 1000,
|
||||
timeoutFactor: 2,
|
||||
})
|
||||
|
||||
const res = await agent.request({
|
||||
method: 'GET',
|
||||
origin: 'http://example.com',
|
||||
path: '/',
|
||||
})
|
||||
|
||||
console.log(res.statusCode)
|
||||
console.log(await res.body.text())
|
||||
```
|
||||
|
||||
### `retryAgent.dispatch(options, handler)`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.7.0
|
||||
-->
|
||||
|
||||
* `options` {DispatchOptions} The dispatch options, identical to
|
||||
[`dispatcher.dispatch()`][].
|
||||
* `handler` {DispatchHandler} The dispatch handler.
|
||||
* Returns: {boolean}
|
||||
|
||||
Dispatches `options` through the wrapped dispatcher, installing a
|
||||
[`RetryHandler`][] that applies the configured retry policy before delegating to
|
||||
`handler`. Returns `false` if the dispatcher is busy and the caller should wait
|
||||
before dispatching again, otherwise `true`.
|
||||
|
||||
### `retryAgent.close()`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.7.0
|
||||
-->
|
||||
|
||||
* Returns: {Promise} Resolves when the wrapped dispatcher has closed.
|
||||
|
||||
Gracefully closes the wrapped dispatcher, waiting for in-flight requests to
|
||||
complete. Equivalent to calling [`dispatcher.close()`][] on the wrapped
|
||||
dispatcher.
|
||||
|
||||
### `retryAgent.destroy()`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.7.0
|
||||
-->
|
||||
|
||||
* Returns: {Promise} Resolves when the wrapped dispatcher has been destroyed.
|
||||
|
||||
Forcefully destroys the wrapped dispatcher, aborting all pending requests.
|
||||
Equivalent to calling [`dispatcher.destroy()`][] on the wrapped dispatcher.
|
||||
|
||||
[`Dispatcher`]: Dispatcher.md#class-dispatcher
|
||||
[`RetryHandler`]: RetryHandler.md#class-retryhandler
|
||||
[`dispatcher.close()`]: Dispatcher.md#dispatcherclosecallback
|
||||
[`dispatcher.destroy()`]: Dispatcher.md#dispatcherdestroyerror-callback
|
||||
[`dispatcher.dispatch()`]: Dispatcher.md#dispatcherdispatchoptions-handler
|
||||
[`retryAgent.close()`]: #retryagentclose
|
||||
[`retryAgent.destroy()`]: #retryagentdestroy
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
# RetryHandler
|
||||
|
||||
<!--introduced_in=v5.28.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/handler/retry-handler.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A [`Dispatcher`][] handler that automatically retries a request when it fails
|
||||
with a recoverable network error or an eligible HTTP status code. It wraps an
|
||||
inner handler and re-dispatches the request, applying an exponential backoff and
|
||||
honouring the `Retry-After` response header. When a response is partially
|
||||
consumed before the failure, the handler resumes the download with a `Range`
|
||||
request guarded by the original `ETag`.
|
||||
|
||||
The handler is most often used indirectly through [`RetryAgent`][], but it can
|
||||
also be supplied directly to [`dispatcher.dispatch()`][] for fine-grained
|
||||
control over the retry behaviour.
|
||||
|
||||
```mjs
|
||||
import { RetryHandler } from 'undici'
|
||||
```
|
||||
|
||||
## Class: `RetryHandler`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.28.0
|
||||
-->
|
||||
|
||||
* Extends: {DispatchHandler}
|
||||
|
||||
Implements the [`DispatchHandler`][] interface. An instance is consumed by a
|
||||
single dispatch call and forwards the dispatch lifecycle to the inner `handler`,
|
||||
re-issuing the request through the supplied `dispatch` function whenever a retry
|
||||
is warranted.
|
||||
|
||||
> **Note:** The `RetryHandler` does not retry over stateful bodies (for example
|
||||
> streams or `AsyncIterable`), because once consumed they cannot be replayed. In
|
||||
> these situations the body is identified as stateful and the request is rejected
|
||||
> with the `UND_ERR_REQ_RETRY` error instead of being retried.
|
||||
|
||||
### `new RetryHandler(options, retryHandlers)`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.28.0
|
||||
changes:
|
||||
- version: v7.0.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/3883
|
||||
description: Reimplemented on top of the new dispatch lifecycle hooks.
|
||||
-->
|
||||
|
||||
* `options` {DispatchOptions} The dispatch options for the request, extended with
|
||||
an optional `retryOptions` field. Type is
|
||||
`DispatchOptions & { retryOptions?: RetryOptions }`.
|
||||
* `retryOptions` {RetryOptions} (optional) Configuration controlling when and
|
||||
how the request is retried. See [`RetryOptions`](#retryoptions).
|
||||
* `retryHandlers` {RetryHandlers} The handlers used to drive the retry loop. See
|
||||
[`RetryHandlers`](#parameter-retryhandlers).
|
||||
* Returns: {RetryHandler}
|
||||
|
||||
#### `RetryOptions`
|
||||
|
||||
* `throwOnError` {boolean} When `true`, an error is thrown on the last retry
|
||||
attempt and propagated to the inner handler; when `false`, the failing response
|
||||
is passed through instead, which is useful when the error body is needed or a
|
||||
custom error handler is in place. **Default:** `true`.
|
||||
* `retry` {Function} Callback invoked on every retry iteration to decide whether
|
||||
another attempt should be made. It receives the error, the retry context, and a
|
||||
callback. Call the callback with an `Error` to stop retrying, or with `null` to
|
||||
schedule another attempt. **Default:** the built-in retry strategy described
|
||||
below.
|
||||
* `err` {Error} The error that triggered the retry.
|
||||
* `context` {RetryContext} The current retry context. See
|
||||
[`RetryContext`](#retrycontext).
|
||||
* `callback` {Function} Signals the outcome of this iteration.
|
||||
* `result` {Error|null} (optional) An `Error` to abort retrying, or `null` to
|
||||
retry.
|
||||
* `maxRetries` {number} Maximum number of retries allowed. **Default:** `5`.
|
||||
* `maxTimeout` {number} Maximum number of milliseconds to wait between retries.
|
||||
**Default:** `30000` (30 seconds).
|
||||
* `minTimeout` {number} Initial number of milliseconds to wait before the first
|
||||
retry. **Default:** `500` (half a second).
|
||||
* `timeoutFactor` {number} Multiplier applied to the timeout between successive
|
||||
retries to produce an exponential backoff. **Default:** `2`.
|
||||
* `retryAfter` {boolean} When `true`, the delay before the next retry is inferred
|
||||
from the `Retry-After` response header when present. **Default:** `true`.
|
||||
* `methods` {string[]} HTTP methods that are eligible for retrying. **Default:**
|
||||
`['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE']`.
|
||||
* `statusCodes` {number[]} HTTP status codes that trigger a retry. **Default:**
|
||||
`[500, 502, 503, 504, 429]`.
|
||||
* `errorCodes` {string[]} Network error codes that trigger a retry. **Default:**
|
||||
`['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN', 'ENETUNREACH', 'EHOSTDOWN', 'EHOSTUNREACH', 'EPIPE', 'UND_ERR_SOCKET']`.
|
||||
|
||||
The default `retry` strategy computes the delay before the next attempt as
|
||||
`minTimeout * timeoutFactor ** (counter - 1)`, capped at `maxTimeout`. When a
|
||||
`Retry-After` header is present it takes precedence (interpreted as seconds, or as
|
||||
an HTTP date), still capped at `maxTimeout`. The default strategy stops retrying
|
||||
once `counter` exceeds `maxRetries`, when the error code is not in `errorCodes`,
|
||||
when the method is not in `methods`, or when the response status code is not in
|
||||
`statusCodes`.
|
||||
|
||||
#### `RetryContext`
|
||||
|
||||
* `state` {RetryState} The current retry state. See [`RetryState`](#retrystate).
|
||||
* `opts` {DispatchOptions} The dispatch options passed to the handler, including
|
||||
the resolved `retryOptions`. Type is
|
||||
`DispatchOptions & { retryOptions?: RetryOptions }`.
|
||||
|
||||
The context object passed as the second argument to the `retry` callback.
|
||||
|
||||
#### `RetryState`
|
||||
|
||||
* `counter` {number} The current retry attempt, starting at `1` for the first
|
||||
retry.
|
||||
|
||||
Represents the retry state for a given request.
|
||||
|
||||
#### Parameter: `RetryHandlers`
|
||||
|
||||
* `dispatch` {Function} The dispatch function called to (re-)issue the request on
|
||||
every attempt. Type is `(options, handler) => boolean`.
|
||||
* `handler` {DispatchHandler} The inner handler invoked once the request succeeds
|
||||
or the retries are exhausted.
|
||||
|
||||
### Examples
|
||||
|
||||
```mjs
|
||||
import { Client, RetryHandler } from 'undici'
|
||||
|
||||
const client = new Client(`http://localhost:${server.address().port}`)
|
||||
const chunks = []
|
||||
|
||||
const handler = new RetryHandler(
|
||||
{
|
||||
...dispatchOptions,
|
||||
retryOptions: {
|
||||
// Custom retry decision function.
|
||||
retry (err, { state, opts }, callback) {
|
||||
if (err.code === 'UND_ERR_DESTROYED') {
|
||||
callback(err)
|
||||
return
|
||||
}
|
||||
|
||||
if (err.statusCode === 206) {
|
||||
callback(err)
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => callback(null), 1000)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dispatch (...args) {
|
||||
return client.dispatch(...args)
|
||||
},
|
||||
handler: {
|
||||
onRequestStart () {},
|
||||
onResponseStart (controller, status, headers) {
|
||||
// Do something with the response headers.
|
||||
},
|
||||
onResponseData (controller, chunk) {
|
||||
chunks.push(chunk)
|
||||
},
|
||||
onResponseEnd () {},
|
||||
onResponseError (controller, err) {
|
||||
// Handle the error.
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
client.dispatch(dispatchOptions, handler)
|
||||
```
|
||||
|
||||
A minimal handler that relies entirely on the default retry options:
|
||||
|
||||
```mjs
|
||||
import { Client, RetryHandler } from 'undici'
|
||||
|
||||
const client = new Client(`http://localhost:${server.address().port}`)
|
||||
|
||||
const handler = new RetryHandler(dispatchOptions, {
|
||||
dispatch: client.dispatch.bind(client),
|
||||
handler: {
|
||||
onRequestStart () {},
|
||||
onResponseStart (controller, status, headers) {},
|
||||
onResponseData (controller, chunk) {},
|
||||
onResponseEnd () {},
|
||||
onResponseError (controller, err) {}
|
||||
}
|
||||
})
|
||||
|
||||
client.dispatch(dispatchOptions, handler)
|
||||
```
|
||||
|
||||
[`Dispatcher`]: Dispatcher.md#class-dispatcher
|
||||
[`DispatchHandler`]: Dispatcher.md#parameter-dispatchhandler
|
||||
[`RetryAgent`]: RetryAgent.md#class-retryagent
|
||||
[`dispatcher.dispatch()`]: Dispatcher.md#dispatcherdispatchoptions-handler
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
# RoundRobinPool
|
||||
|
||||
<!--introduced_in=v7.17.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/dispatcher/round-robin-pool.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A pool of [`Client`][] instances connected to the same upstream target that
|
||||
selects clients in a round-robin fashion.
|
||||
|
||||
Unlike [`Pool`][], which always reuses the first available client,
|
||||
`RoundRobinPool` cycles through its clients so that requests are distributed
|
||||
evenly across every open connection. This is useful when the upstream target is
|
||||
fronted by a load balancer that distributes TCP connections across multiple
|
||||
backend servers (for example, a Kubernetes Service): each connection is pinned
|
||||
to a backend by the load balancer, and `RoundRobinPool` spreads requests across
|
||||
those connections so every backend receives a comparable share of traffic.
|
||||
|
||||
Requests are not guaranteed to be dispatched in the order they were invoked.
|
||||
|
||||
```mjs
|
||||
import { RoundRobinPool } from 'undici'
|
||||
|
||||
const pool = new RoundRobinPool('http://localhost:3000', { connections: 10 })
|
||||
```
|
||||
|
||||
`RoundRobinPool` distributes HTTP requests evenly across TCP connections, not
|
||||
across backend servers directly. Even backend distribution therefore depends on
|
||||
the load balancer assigning different connections to different backends (for
|
||||
example, round-robin, random, or least-connections without client affinity). If
|
||||
the load balancer pins all connections from one source to the same backend (for
|
||||
example, source-IP affinity or sticky sessions), consider [`BalancedPool`][]
|
||||
with the individual backend addresses instead.
|
||||
|
||||
## Class: `RoundRobinPool`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
* Extends: {Dispatcher}
|
||||
|
||||
### `new RoundRobinPool(url[, options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
* `url` {URL|string} The upstream target. It should only include the
|
||||
**protocol, hostname, and port**.
|
||||
* `options` {RoundRobinPoolOptions} (optional)
|
||||
|
||||
#### Parameter: `RoundRobinPoolOptions`
|
||||
|
||||
Extends: {ClientOptions}
|
||||
|
||||
* `factory` {Function} A function used to create the underlying {Client}
|
||||
instances. **Default:** `(origin, opts) => new Client(origin, opts)`.
|
||||
* `origin` {URL}
|
||||
* `opts` {Object}
|
||||
* Returns: {Dispatcher}
|
||||
* `connections` {number|null} (optional) The maximum number of {Client}
|
||||
instances to create. When set to `null`, the `RoundRobinPool` instance creates
|
||||
an unlimited number of {Client} instances. **Default:** `null`.
|
||||
* `clientTtl` {number|null} (optional) The amount of time, in milliseconds,
|
||||
before a {Client} instance is removed from the `RoundRobinPool` and closed.
|
||||
When set to `null`, {Client} instances are not removed or closed based on age.
|
||||
**Default:** `null`.
|
||||
|
||||
`RoundRobinPool` inherits all [`Client`][] options. A [`Client`][] instance is
|
||||
created lazily on the first dispatch and additional instances are created on
|
||||
demand, up to `connections`, when every existing client is busy.
|
||||
|
||||
### `roundRobinPool.closed`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
* Type: {boolean}
|
||||
|
||||
`true` after `roundRobinPool.close()` has been called.
|
||||
|
||||
### `roundRobinPool.destroyed`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
* Type: {boolean}
|
||||
|
||||
`true` after `roundRobinPool.destroy()` has been called, or after
|
||||
`roundRobinPool.close()` has been called and the pool shutdown has completed.
|
||||
|
||||
### `roundRobinPool.stats`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
* Type: {PoolStats}
|
||||
|
||||
Aggregate connection statistics for the pool. See [`PoolStats`][].
|
||||
|
||||
### `roundRobinPool.close([callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
Closes the pool and gracefully waits for enqueued requests to complete before
|
||||
resolving. Implements [`dispatcher.close([callback])`][].
|
||||
|
||||
### `roundRobinPool.destroy([error[, callback]])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
Destroys the pool abruptly. All pending and running requests are aborted with
|
||||
the given `error`. Implements [`dispatcher.destroy([error[, callback]])`][].
|
||||
|
||||
### `roundRobinPool.connect(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
Starts two-way communications with the requested resource using
|
||||
[HTTP CONNECT][]. See [`dispatcher.connect(options[, callback])`][].
|
||||
|
||||
### `roundRobinPool.dispatch(options, handler)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
Dispatches a request through the next client selected in round-robin order.
|
||||
Implements [`dispatcher.dispatch(options, handler)`][].
|
||||
|
||||
### `roundRobinPool.pipeline(options, handler)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
For easy use with [`stream.pipeline`][]. See
|
||||
[`dispatcher.pipeline(options, handler)`][].
|
||||
|
||||
### `roundRobinPool.request(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
Performs an HTTP request. See [`dispatcher.request(options[, callback])`][].
|
||||
|
||||
### `roundRobinPool.stream(options, factory[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
A faster version of [`roundRobinPool.request()`][]. See
|
||||
[`dispatcher.stream(options, factory[, callback])`][].
|
||||
|
||||
### `roundRobinPool.upgrade(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
Upgrades a connection to a different protocol. See
|
||||
[`dispatcher.upgrade(options[, callback])`][].
|
||||
|
||||
### Event: `'connect'`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
See [Dispatcher Event: `'connect'`][].
|
||||
|
||||
### Event: `'disconnect'`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
See [Dispatcher Event: `'disconnect'`][].
|
||||
|
||||
### Event: `'drain'`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.17.0
|
||||
-->
|
||||
|
||||
See [Dispatcher Event: `'drain'`][].
|
||||
|
||||
## Example
|
||||
|
||||
```mjs
|
||||
import { RoundRobinPool } from 'undici'
|
||||
|
||||
const pool = new RoundRobinPool('http://localhost:3000', {
|
||||
connections: 10
|
||||
})
|
||||
|
||||
// Requests are distributed evenly across all 10 connections.
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const { body } = await pool.request({
|
||||
path: '/api/data',
|
||||
method: 'GET'
|
||||
})
|
||||
console.log(await body.json())
|
||||
}
|
||||
|
||||
await pool.close()
|
||||
```
|
||||
|
||||
[HTTP CONNECT]: https://developer.mozilla.org/docs/Web/HTTP/Methods/CONNECT
|
||||
[`BalancedPool`]: BalancedPool.md#class-balancedpool
|
||||
[`Client`]: Client.md#class-client
|
||||
[`Pool`]: Pool.md#class-pool
|
||||
[`PoolStats`]: PoolStats.md#class-poolstats
|
||||
[`dispatcher.close([callback])`]: Dispatcher.md#dispatcherclosecallback-promise
|
||||
[`dispatcher.connect(options[, callback])`]: Dispatcher.md#dispatcherconnectoptions-callback
|
||||
[`dispatcher.destroy([error[, callback]])`]: Dispatcher.md#dispatcherdestroyerror-callback-promise
|
||||
[`dispatcher.dispatch(options, handler)`]: Dispatcher.md#dispatcherdispatchoptions-handler
|
||||
[`dispatcher.pipeline(options, handler)`]: Dispatcher.md#dispatcherpipelineoptions-handler
|
||||
[`dispatcher.request(options[, callback])`]: Dispatcher.md#dispatcherrequestoptions-callback
|
||||
[`dispatcher.stream(options, factory[, callback])`]: Dispatcher.md#dispatcherstreamoptions-factory-callback
|
||||
[`dispatcher.upgrade(options[, callback])`]: Dispatcher.md#dispatcherupgradeoptions-callback
|
||||
[`roundRobinPool.request()`]: #roundrobinpoolrequestoptions-callback
|
||||
[`stream.pipeline`]: https://nodejs.org/api/stream.html#streampipelinesource-transforms-destination-callback
|
||||
[Dispatcher Event: `'connect'`]: Dispatcher.md#event-connect
|
||||
[Dispatcher Event: `'disconnect'`]: Dispatcher.md#event-disconnect
|
||||
[Dispatcher Event: `'drain'`]: Dispatcher.md#event-drain
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
# SnapshotAgent
|
||||
|
||||
<!--introduced_in=v7.13.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/mock/snapshot-agent.js -->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
`SnapshotAgent` records real HTTP responses and replays them on later requests,
|
||||
allowing tests to run against deterministic, captured data instead of a live
|
||||
network. It extends [`MockAgent`][], so it can be installed with
|
||||
[`setGlobalDispatcher()`][] or passed explicitly through the `dispatcher`
|
||||
option, and it works with every undici API (`fetch`, `request`, `stream`,
|
||||
`pipeline`, and so on).
|
||||
|
||||
A `SnapshotAgent` operates in one of three modes. In `'record'` mode it performs
|
||||
real requests and writes the responses to a snapshot file. In `'playback'` mode
|
||||
it serves responses from the snapshot file without touching the network. In
|
||||
`'update'` mode it replays existing snapshots and records any request that has no
|
||||
matching snapshot.
|
||||
|
||||
```mjs
|
||||
import { SnapshotAgent, setGlobalDispatcher, fetch } from 'undici'
|
||||
|
||||
const agent = new SnapshotAgent({
|
||||
mode: 'record',
|
||||
snapshotPath: './snapshots/api.json'
|
||||
})
|
||||
setGlobalDispatcher(agent)
|
||||
|
||||
const response = await fetch('https://api.example.com/users')
|
||||
const users = await response.json()
|
||||
|
||||
await agent.close()
|
||||
```
|
||||
|
||||
Constructing a `SnapshotAgent` emits an `ExperimentalWarning` once per process.
|
||||
|
||||
## Class: `SnapshotAgent`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.13.0
|
||||
-->
|
||||
|
||||
* Extends: {MockAgent}
|
||||
|
||||
The captured interactions are managed by an internal `SnapshotRecorder`, which
|
||||
is accessible through [`agent.getRecorder()`][]. Because `SnapshotAgent` extends
|
||||
[`MockAgent`][], all `MockAgent` and [`Dispatcher`][] methods and events are also
|
||||
available.
|
||||
|
||||
### `new SnapshotAgent([options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.13.0
|
||||
-->
|
||||
|
||||
* `options` {Object} (optional) Accepts all [`MockAgent`][] options plus the
|
||||
following snapshot-specific options.
|
||||
* `mode` {string} The snapshot mode. One of `'record'`, `'playback'`, or
|
||||
`'update'`. **Default:** `'record'`.
|
||||
* `snapshotPath` {string} Path to the snapshot file used for loading and
|
||||
saving. Required when `mode` is `'playback'` or `'update'`. **Default:**
|
||||
`null`.
|
||||
* `maxSnapshots` {number} Maximum number of snapshots to keep in memory.
|
||||
**Default:** `Infinity`.
|
||||
* `autoFlush` {boolean} When `true`, snapshots are written to `snapshotPath`
|
||||
automatically as they are recorded. **Default:** `false`.
|
||||
* `flushInterval` {number} Interval, in milliseconds, between automatic flushes
|
||||
when `autoFlush` is enabled. **Default:** `30000`.
|
||||
* `matchHeaders` {string[]} Names of the only headers to include when matching
|
||||
a request against a snapshot. When omitted, all headers are matched.
|
||||
* `ignoreHeaders` {string[]} Header names to ignore when matching a request.
|
||||
* `excludeHeaders` {string[]} Header names to strip from snapshots entirely,
|
||||
so that sensitive values are never written to disk.
|
||||
* `matchBody` {boolean} When `true`, the request body is included in request
|
||||
matching. **Default:** `true`.
|
||||
* `normalizeBody` {Function} Normalizes the request body before it is matched
|
||||
or stored, for example to strip volatile fields such as timestamps. Only
|
||||
applied when `matchBody` is `true`.
|
||||
* `body` {string|Buffer|null|undefined} The raw request body.
|
||||
* Returns: {string} The normalized body used for matching.
|
||||
* `matchQuery` {boolean} When `true`, query parameters are included in request
|
||||
matching. **Default:** `true`.
|
||||
* `normalizeQuery` {Function} Normalizes the query string before it is matched,
|
||||
for example to strip cache-busting parameters. Only applied when `matchQuery`
|
||||
is `true`.
|
||||
* `query` {URLSearchParams} The parsed query parameters.
|
||||
* Returns: {string} The normalized query string used for matching.
|
||||
* `caseSensitive` {boolean} When `true`, header matching is case-sensitive.
|
||||
**Default:** `false`.
|
||||
* `shouldRecord` {Function} Decides whether a given request is recorded. Only
|
||||
used in `'record'` and `'update'` modes.
|
||||
* `requestOpts` {Object} The dispatch options of the request.
|
||||
* Returns: {boolean} `true` to record the request.
|
||||
* `shouldPlayback` {Function} Decides whether a given request is served from a
|
||||
snapshot. Only used in `'playback'` and `'update'` modes.
|
||||
* `requestOpts` {Object} The dispatch options of the request.
|
||||
* Returns: {boolean} `true` to play the request back from a snapshot.
|
||||
* `excludeUrls` {Array<string|RegExp>} URL patterns that are excluded from
|
||||
recording and playback. String patterns match case-insensitively as
|
||||
substrings of the URL; `RegExp` patterns are tested against the URL. Matching
|
||||
requests are passed through to a real [`Agent`][] instead.
|
||||
|
||||
Creates a new `SnapshotAgent`. Throws an [`InvalidArgumentError`][] when `mode`
|
||||
is `'playback'` or `'update'` and `snapshotPath` is not provided, and throws an
|
||||
`InvalidArgumentError` when `mode` is not one of the supported values.
|
||||
|
||||
When `mode` is `'playback'` or `'update'` and `snapshotPath` is set, snapshots
|
||||
are loaded from the file automatically; a missing file is ignored until the first
|
||||
request requires it.
|
||||
|
||||
```mjs
|
||||
import { SnapshotAgent, setGlobalDispatcher } from 'undici'
|
||||
|
||||
const agent = new SnapshotAgent({
|
||||
mode: 'playback',
|
||||
snapshotPath: './snapshots/api.json'
|
||||
})
|
||||
setGlobalDispatcher(agent)
|
||||
```
|
||||
|
||||
The mode controls how requests are handled:
|
||||
|
||||
* `'record'` performs the real request and stores the response.
|
||||
|
||||
```mjs
|
||||
import { SnapshotAgent, setGlobalDispatcher, fetch } from 'undici'
|
||||
|
||||
const agent = new SnapshotAgent({
|
||||
mode: 'record',
|
||||
snapshotPath: './snapshots/api.json'
|
||||
})
|
||||
setGlobalDispatcher(agent)
|
||||
|
||||
await fetch('https://api.example.com/users')
|
||||
await agent.saveSnapshots()
|
||||
```
|
||||
|
||||
* `'playback'` serves the recorded response without any network access. A
|
||||
request with no matching snapshot rejects with an [`UndiciError`][] whose
|
||||
message begins with `No snapshot found`.
|
||||
|
||||
```mjs
|
||||
import { SnapshotAgent, setGlobalDispatcher, fetch } from 'undici'
|
||||
|
||||
const agent = new SnapshotAgent({
|
||||
mode: 'playback',
|
||||
snapshotPath: './snapshots/api.json'
|
||||
})
|
||||
setGlobalDispatcher(agent)
|
||||
|
||||
const response = await fetch('https://api.example.com/users')
|
||||
```
|
||||
|
||||
* `'update'` replays an existing snapshot when one is found and otherwise records
|
||||
the request like `'record'` mode.
|
||||
|
||||
```mjs
|
||||
import { SnapshotAgent, setGlobalDispatcher, fetch } from 'undici'
|
||||
|
||||
const agent = new SnapshotAgent({
|
||||
mode: 'update',
|
||||
snapshotPath: './snapshots/api.json'
|
||||
})
|
||||
setGlobalDispatcher(agent)
|
||||
|
||||
await fetch('https://api.example.com/new-endpoint')
|
||||
```
|
||||
|
||||
### `agent.loadSnapshots([filePath])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.13.0
|
||||
-->
|
||||
|
||||
* `filePath` {string} (optional) Path to read snapshots from. **Default:** the
|
||||
`snapshotPath` given to the constructor.
|
||||
* Returns: {Promise<void>} Resolves when the snapshots have been loaded.
|
||||
|
||||
Loads snapshots from disk into memory. In `'playback'` mode this also installs
|
||||
the corresponding [`MockAgent`][] interceptors so that subsequent requests are
|
||||
matched against the loaded snapshots.
|
||||
|
||||
```mjs
|
||||
await agent.loadSnapshots('./snapshots/api.json')
|
||||
```
|
||||
|
||||
### `agent.saveSnapshots([filePath])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.13.0
|
||||
-->
|
||||
|
||||
* `filePath` {string} (optional) Path to write snapshots to. **Default:** the
|
||||
`snapshotPath` given to the constructor.
|
||||
* Returns: {Promise<void>} Resolves when the snapshots have been written.
|
||||
|
||||
Writes all recorded snapshots to disk.
|
||||
|
||||
```mjs
|
||||
await agent.saveSnapshots('./snapshots/api.json')
|
||||
```
|
||||
|
||||
### `agent.getRecorder()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.13.0
|
||||
-->
|
||||
|
||||
* Returns: {SnapshotRecorder} The internal recorder that stores the captured
|
||||
interactions.
|
||||
|
||||
Returns the underlying `SnapshotRecorder`. The recorder exposes lower-level
|
||||
operations over the captured data, including `record()`, `findSnapshot()`,
|
||||
`getSnapshots()`, `size()`, and `clear()`. The recorder is an internal type
|
||||
returned for inspection; it is not exported from the package root.
|
||||
|
||||
```mjs
|
||||
const recorder = agent.getRecorder()
|
||||
console.log(`Recorded ${recorder.size()} interactions`)
|
||||
```
|
||||
|
||||
### `agent.getMode()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.13.0
|
||||
-->
|
||||
|
||||
* Returns: {string} The current mode: `'record'`, `'playback'`, or `'update'`.
|
||||
|
||||
Returns the mode the agent was constructed with.
|
||||
|
||||
### `agent.clearSnapshots()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.13.0
|
||||
-->
|
||||
|
||||
* Returns: {undefined}
|
||||
|
||||
Removes all snapshots from memory.
|
||||
|
||||
```mjs
|
||||
agent.clearSnapshots()
|
||||
```
|
||||
|
||||
### `agent.resetCallCounts()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.13.0
|
||||
-->
|
||||
|
||||
* Returns: {undefined}
|
||||
|
||||
Resets the call count of every snapshot to zero. Useful between tests when the
|
||||
same agent is reused, since matching a snapshot in `'playback'` mode increments
|
||||
its call count.
|
||||
|
||||
```mjs
|
||||
agent.resetCallCounts()
|
||||
```
|
||||
|
||||
### `agent.deleteSnapshot(requestOpts)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.13.0
|
||||
-->
|
||||
|
||||
* `requestOpts` {Object} Dispatch options identifying the snapshot to remove.
|
||||
* Returns: {boolean} `true` if a matching snapshot was deleted, `false` if none
|
||||
was found.
|
||||
|
||||
Deletes a single snapshot that matches the given request options.
|
||||
|
||||
```mjs
|
||||
const deleted = agent.deleteSnapshot({
|
||||
method: 'GET',
|
||||
origin: 'https://api.example.com',
|
||||
path: '/users'
|
||||
})
|
||||
```
|
||||
|
||||
### `agent.getSnapshotInfo(requestOpts)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.13.0
|
||||
-->
|
||||
|
||||
* `requestOpts` {Object} Dispatch options identifying the snapshot to inspect.
|
||||
* Returns: {Object|null} Information about the matching snapshot, or `null` when
|
||||
none is found.
|
||||
* `hash` {string} The hash used to key the snapshot.
|
||||
* `request` {Object} The recorded request.
|
||||
* `method` {string} The request method.
|
||||
* `url` {string} The request URL.
|
||||
* `headers` {Record<string, string>} The recorded request headers.
|
||||
* `body` {string} The recorded request body, when present.
|
||||
* `responseCount` {number} The number of recorded responses for the request.
|
||||
* `callCount` {number} The number of times the snapshot has been matched.
|
||||
* `timestamp` {string} The ISO timestamp at which the snapshot was recorded.
|
||||
|
||||
Returns metadata about a snapshot without returning the response payload.
|
||||
|
||||
```mjs
|
||||
const info = agent.getSnapshotInfo({
|
||||
method: 'GET',
|
||||
origin: 'https://api.example.com',
|
||||
path: '/users'
|
||||
})
|
||||
```
|
||||
|
||||
### `agent.replaceSnapshots(snapshotData)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.13.0
|
||||
-->
|
||||
|
||||
* `snapshotData` {Array} The snapshot data to load, replacing any existing
|
||||
snapshots.
|
||||
* `hash` {string} The hash used to key the snapshot.
|
||||
* `snapshot` {Object} The snapshot entry.
|
||||
* Returns: {undefined}
|
||||
|
||||
Replaces all in-memory snapshots with the provided data.
|
||||
|
||||
```mjs
|
||||
const recorder = agent.getRecorder()
|
||||
const snapshots = recorder.getSnapshots()
|
||||
|
||||
agent.replaceSnapshots(snapshots.map((snapshot, index) => ({
|
||||
hash: `snapshot-${index}`,
|
||||
snapshot
|
||||
})))
|
||||
```
|
||||
|
||||
### `agent.close()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.13.0
|
||||
-->
|
||||
|
||||
* Returns: {Promise<void>} Resolves once the agent and its resources are closed.
|
||||
|
||||
Closes the agent and releases its resources. In `'record'` and `'update'` modes
|
||||
the recorder is flushed to disk first. In `'playback'` mode nothing is written,
|
||||
because matching a snapshot mutates its call count and saving would needlessly
|
||||
rewrite the snapshot file. The real [`Agent`][], when one was created, and the
|
||||
underlying [`MockAgent`][] are closed as well.
|
||||
|
||||
```mjs
|
||||
await agent.close()
|
||||
```
|
||||
|
||||
[`Agent`]: Agent.md#class-agent
|
||||
[`Dispatcher`]: Dispatcher.md#class-dispatcher
|
||||
[`InvalidArgumentError`]: Errors.md#undicierrorsundiciinvalidargumenterror
|
||||
[`MockAgent`]: MockAgent.md#class-mockagent
|
||||
[`UndiciError`]: Errors.md#undicierrorsundicierror
|
||||
[`agent.getRecorder()`]: #agentgetrecorder
|
||||
[`setGlobalDispatcher()`]: Dispatcher.md#setglobaldispatcherdispatcher
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
# Socks5ProxyAgent
|
||||
|
||||
<!--introduced_in=v7.23.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/dispatcher/socks5-proxy-agent.js -->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
A [`Dispatcher`][] implementation that routes requests through a [SOCKS5][]
|
||||
proxy server. It tunnels both HTTP and HTTPS traffic over the proxy, supports
|
||||
optional username/password authentication, and pools connections per origin so
|
||||
that subsequent requests to the same host reuse the established tunnel.
|
||||
|
||||
DNS resolution is delegated to the proxy: target host names are sent to the
|
||||
proxy as domain names rather than being resolved locally. For HTTPS targets the
|
||||
TLS session is negotiated end-to-end through the tunnel, so the proxy only sees
|
||||
encrypted bytes.
|
||||
|
||||
Import it from `'undici'`:
|
||||
|
||||
```mjs
|
||||
import { Socks5ProxyAgent } from 'undici'
|
||||
```
|
||||
|
||||
Constructing the agent emits a one-time `ExperimentalWarning`, reflecting the
|
||||
experimental status of SOCKS5 support.
|
||||
|
||||
## Class: `Socks5ProxyAgent`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.23.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Extends: {Dispatcher}
|
||||
|
||||
Routes dispatched requests through a SOCKS5 proxy. Register an instance as the
|
||||
global dispatcher with [`setGlobalDispatcher()`][], or pass it per request via
|
||||
the `dispatcher` option.
|
||||
|
||||
### `new Socks5ProxyAgent(proxyUrl[, options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.23.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* `proxyUrl` {string|URL} The SOCKS5 proxy server URL. Must use the `socks5:` or
|
||||
`socks:` protocol. Credentials may be embedded in the URL userinfo (for
|
||||
example `socks5://user:pass@host:1080`).
|
||||
* `options` {Object} (optional) Extends {PoolOptions}; the pool options are
|
||||
applied to the per-origin pools created behind the tunnel.
|
||||
* `headers` {Object} Additional headers to send with the proxy connection.
|
||||
**Default:** `{}`.
|
||||
* `username` {string} Username for SOCKS5 authentication. Takes precedence over
|
||||
a username embedded in `proxyUrl`. **Default:** the URL username, if any.
|
||||
* `password` {string} Password for SOCKS5 authentication. Takes precedence over
|
||||
a password embedded in `proxyUrl`. **Default:** the URL password, if any.
|
||||
* `connect` {Function} Custom connector used to open the socket to the proxy.
|
||||
**Default:** a connector built from `proxyTls`.
|
||||
* `connectTimeout` {number} Maximum time in milliseconds for each proxy
|
||||
connection, SOCKS5 negotiation, and target TLS negotiation stage. A value of
|
||||
`0` disables these stage timeouts. `proxyTls.timeout` and
|
||||
`requestTls.timeout` override it for their respective TLS stages.
|
||||
**Default:** `5000`.
|
||||
* `proxyTls` {BuildOptions} TLS options for the connection to the proxy itself
|
||||
(SOCKS5 over TLS). When set, the proxy connection is established over TLS and
|
||||
`servername` defaults to the proxy host name.
|
||||
* `requestTls` {BuildOptions} TLS options applied to the end-to-end connection
|
||||
to an HTTPS target through the tunnel, such as `ca`, `cert`, `key`,
|
||||
`rejectUnauthorized`, and `servername`. `servername` defaults to the target
|
||||
host name.
|
||||
|
||||
Throws an `InvalidArgumentError` if `proxyUrl` is missing or does not use the
|
||||
`socks5:` or `socks:` protocol, or if `connectTimeout`, `proxyTls.timeout`, or
|
||||
`requestTls.timeout` is not a finite, non-negative number.
|
||||
|
||||
```mjs
|
||||
import { Socks5ProxyAgent } from 'undici'
|
||||
|
||||
// Without authentication.
|
||||
const agent = new Socks5ProxyAgent('socks5://localhost:1080')
|
||||
|
||||
// With credentials in the URL.
|
||||
const authAgent = new Socks5ProxyAgent('socks5://user:pass@localhost:1080')
|
||||
|
||||
// With credentials and pool options.
|
||||
const configuredAgent = new Socks5ProxyAgent('socks5://localhost:1080', {
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
connections: 10
|
||||
})
|
||||
```
|
||||
|
||||
Using the agent as the global dispatcher:
|
||||
|
||||
```mjs
|
||||
import { setGlobalDispatcher, request, Socks5ProxyAgent } from 'undici'
|
||||
|
||||
const agent = new Socks5ProxyAgent('socks5://localhost:1080')
|
||||
setGlobalDispatcher(agent)
|
||||
|
||||
const { statusCode, body } = await request('http://localhost:3000/foo')
|
||||
console.log('response received', statusCode)
|
||||
|
||||
for await (const data of body) {
|
||||
console.log('data', data.toString('utf8'))
|
||||
}
|
||||
```
|
||||
|
||||
Using the agent per request:
|
||||
|
||||
```mjs
|
||||
import { request, Socks5ProxyAgent } from 'undici'
|
||||
|
||||
const agent = new Socks5ProxyAgent('socks5://localhost:1080')
|
||||
|
||||
const { statusCode, body } = await request('http://localhost:3000/foo', {
|
||||
dispatcher: agent
|
||||
})
|
||||
|
||||
console.log('response received', statusCode)
|
||||
```
|
||||
|
||||
HTTPS targets are tunnelled and encrypted end-to-end:
|
||||
|
||||
```mjs
|
||||
import { request, Socks5ProxyAgent } from 'undici'
|
||||
|
||||
const agent = new Socks5ProxyAgent('socks5://localhost:1080')
|
||||
|
||||
const { statusCode, body } = await request('https://api.example.com/data', {
|
||||
dispatcher: agent
|
||||
})
|
||||
|
||||
console.log('Response status:', statusCode)
|
||||
console.log('Response data:', await body.json())
|
||||
```
|
||||
|
||||
### `socks5ProxyAgent.close()`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.23.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Returns: {Promise<void>} Resolves once every per-origin pool has been closed.
|
||||
|
||||
Gracefully closes the agent, waiting for all underlying pools and their
|
||||
connections to drain and close before resolving.
|
||||
|
||||
```mjs
|
||||
import { setGlobalDispatcher, Socks5ProxyAgent } from 'undici'
|
||||
|
||||
const agent = new Socks5ProxyAgent('socks5://localhost:1080')
|
||||
setGlobalDispatcher(agent)
|
||||
|
||||
// ... make requests ...
|
||||
|
||||
await agent.close()
|
||||
```
|
||||
|
||||
### `socks5ProxyAgent.destroy([err])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.23.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* `err` {Error} (optional) The error to reject in-flight requests with.
|
||||
* Returns: {Promise<void>} Resolves once every per-origin pool has been
|
||||
destroyed.
|
||||
|
||||
Forcibly destroys the agent and all underlying connections immediately, without
|
||||
waiting for in-flight requests to complete.
|
||||
|
||||
```mjs
|
||||
import { Socks5ProxyAgent } from 'undici'
|
||||
|
||||
const agent = new Socks5ProxyAgent('socks5://localhost:1080')
|
||||
|
||||
await agent.destroy()
|
||||
```
|
||||
|
||||
### `socks5ProxyAgent.dispatch(options, handlers)`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.23.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* `options` {DispatchOptions}
|
||||
* `handlers` {DispatchHandler}
|
||||
* Returns: {boolean} `false` if the dispatcher is busy and the request should be
|
||||
retried later, otherwise `true`.
|
||||
|
||||
Routes a request through the SOCKS5 tunnel. The agent maintains a [`Pool`][] per
|
||||
target origin; the first request to a given origin establishes the tunnel and
|
||||
upgrades to TLS when the target is HTTPS, and later requests reuse the pool.
|
||||
|
||||
This is the lower-level entry point implementing
|
||||
[`Dispatcher.dispatch(options, handlers)`][]. Prefer
|
||||
[`socks5ProxyAgent.request()`][] for most use cases.
|
||||
|
||||
### `socks5ProxyAgent.request(options[, callback])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.23.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* `options` {DispatchOptions}
|
||||
* `callback` {Function} (optional)
|
||||
* Returns: {Promise|void} A promise resolving to the response when `callback` is
|
||||
omitted.
|
||||
|
||||
Performs a request through the SOCKS5 proxy. Inherited from
|
||||
[`Dispatcher.request(options[, callback])`][]; see that method for the full set
|
||||
of options and the shape of the resolved response.
|
||||
|
||||
```mjs
|
||||
import { fetch, Socks5ProxyAgent } from 'undici'
|
||||
|
||||
const agent = new Socks5ProxyAgent('socks5://localhost:1080')
|
||||
|
||||
const response = await fetch('http://localhost:3000/api/users', {
|
||||
dispatcher: agent
|
||||
})
|
||||
|
||||
console.log('Response status:', response.status)
|
||||
console.log('Response data:', await response.text())
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
SOCKS5 proxy activity can be traced with the `undici:socks5-proxy` debug scope:
|
||||
|
||||
```bash
|
||||
NODE_DEBUG=undici:socks5-proxy node script.js
|
||||
```
|
||||
|
||||
This logs the SOCKS5 handshake, authentication, and tunnel establishment steps.
|
||||
|
||||
[SOCKS5]: https://www.rfc-editor.org/rfc/rfc1928
|
||||
[`Dispatcher.dispatch(options, handlers)`]: Dispatcher.md#dispatcherdispatchoptions-handler
|
||||
[`Dispatcher.request(options[, callback])`]: Dispatcher.md#dispatcherrequestoptions-callback
|
||||
[`Dispatcher`]: Dispatcher.md#class-dispatcher
|
||||
[`Pool`]: Pool.md#class-pool
|
||||
[`setGlobalDispatcher()`]: Dispatcher.md#setglobaldispatcherdispatcher
|
||||
[`socks5ProxyAgent.request()`]: #socks5proxyagentrequestoptions-callback
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
# Util
|
||||
|
||||
<!--introduced_in=v1.2.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/core/util.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
A small set of utility helpers exposed for third-party implementations of the
|
||||
[`Dispatcher`][] API. They cover the header-handling primitives undici uses
|
||||
internally, so that custom dispatchers and handlers can normalize header names
|
||||
and parse raw header lists exactly the way undici does.
|
||||
|
||||
```mjs
|
||||
import { util } from 'undici'
|
||||
|
||||
const { parseHeaders, headerNameToString } = util
|
||||
```
|
||||
|
||||
```cjs
|
||||
const { util } = require('undici')
|
||||
|
||||
const { parseHeaders, headerNameToString } = util
|
||||
```
|
||||
|
||||
## `parseHeaders(headers[, obj])`
|
||||
|
||||
<!-- YAML
|
||||
added: v1.2.0
|
||||
changes:
|
||||
- version: v6.1.0
|
||||
pr-url: https://github.com/nodejs/undici/pull/2501
|
||||
description: Header names supplied as a `Buffer` are accepted and normalized.
|
||||
-->
|
||||
|
||||
* `headers` {Array} A flat list of raw header entries where even indices are
|
||||
header names and odd indices are the corresponding values. Each entry is a
|
||||
{string}, {Buffer}, or an array of {string}/{Buffer}.
|
||||
* `obj` {Record<string, string|string[]>} An object to assign the parsed values
|
||||
to. When omitted, a new object is created. **Default:** `{}`.
|
||||
* Returns: {Record<string, string|string[]>} The object the parsed headers were
|
||||
assigned to. When `obj` is provided, the same reference is returned.
|
||||
|
||||
Receives a flat list of raw header name/value pairs and returns them as an
|
||||
object keyed by lowercased header name. Header names are normalized with
|
||||
[`headerNameToString()`][], and `Buffer` values are decoded as `latin1`. When a
|
||||
header name appears more than once, its values are collected into an array.
|
||||
|
||||
```mjs
|
||||
import { util } from 'undici'
|
||||
|
||||
const raw = ['Content-Type', 'text/plain', 'Set-Cookie', 'a=1', 'Set-Cookie', 'b=2']
|
||||
|
||||
console.log(util.parseHeaders(raw))
|
||||
// { 'content-type': 'text/plain', 'set-cookie': [ 'a=1', 'b=2' ] }
|
||||
```
|
||||
|
||||
## `headerNameToString(value)`
|
||||
|
||||
<!-- YAML
|
||||
added: v6.1.0
|
||||
-->
|
||||
|
||||
* `value` {string|Buffer} The header name to normalize.
|
||||
* Returns: {string} The lowercased header name.
|
||||
|
||||
Returns the lowercased form of a header name. The `value` may be provided as a
|
||||
{string} or as a {Buffer}, in which case it is decoded as `latin1`. Common
|
||||
header names are resolved through an internal lookup table for performance.
|
||||
|
||||
```mjs
|
||||
import { util } from 'undici'
|
||||
|
||||
console.log(util.headerNameToString('Content-Type'))
|
||||
// 'content-type'
|
||||
|
||||
console.log(util.headerNameToString(Buffer.from('X-Custom-Header')))
|
||||
// 'x-custom-header'
|
||||
```
|
||||
|
||||
[`Dispatcher`]: Dispatcher.md#class-dispatcher
|
||||
[`headerNameToString()`]: #headernametostringvalue
|
||||
+536
@@ -0,0 +1,536 @@
|
||||
# WebSocket
|
||||
|
||||
<!--introduced_in=v5.15.0-->
|
||||
<!--type=module-->
|
||||
<!-- source_link=lib/web/websocket/websocket.js -->
|
||||
|
||||
> Stability: 2 - Stable
|
||||
|
||||
The `WebSocket` API provides a way to manage a WebSocket connection to a
|
||||
server, allowing bidirectional communication. It follows the
|
||||
[WHATWG WebSocket specification][] and [RFC 6455][], and is intended to be
|
||||
interchangeable with the global `WebSocket` available in browsers.
|
||||
|
||||
```mjs
|
||||
import { WebSocket } from 'undici'
|
||||
|
||||
const ws = new WebSocket('wss://echo.websocket.events')
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
ws.send('Hello, world!')
|
||||
})
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
console.log('received:', event.data)
|
||||
})
|
||||
```
|
||||
|
||||
In addition to the standard `WebSocket` interface, this module exports the
|
||||
streams-based [`WebSocketStream`][] and [`WebSocketError`][] APIs, as well as a
|
||||
[`ping()`][] helper for sending ping frames.
|
||||
|
||||
## Class: `WebSocket`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* Extends: {EventTarget}
|
||||
|
||||
The `WebSocket` object represents a single WebSocket connection. Constructing a
|
||||
`WebSocket` immediately begins the opening handshake; the connection cannot be
|
||||
reused once it has been closed.
|
||||
|
||||
### `new WebSocket(url[, protocols])`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* `url` {URL|string} The URL of the server to connect to. Must use the `ws:`
|
||||
or `wss:` scheme.
|
||||
* `protocols` {string|string[]|WebSocketInit} A single subprotocol, a list of
|
||||
subprotocols to negotiate with the server, or a `WebSocketInit` object.
|
||||
**Default:** `[]`.
|
||||
* `protocols` {string|string[]} Subprotocol(s) to request the server use.
|
||||
**Default:** `[]`.
|
||||
* `dispatcher` {Dispatcher} The dispatcher used to open the connection.
|
||||
**Default:** the global dispatcher.
|
||||
* `headers` {HeadersInit|null} Additional headers to send with the handshake
|
||||
request. (optional)
|
||||
|
||||
Creates a new `WebSocket` and begins connecting to `url`. The second argument
|
||||
may be a string or array of subprotocols, or a `WebSocketInit` object. The
|
||||
object form is an undici extension and is not available in browsers; it allows a
|
||||
custom `dispatcher` and additional `headers` to be supplied.
|
||||
|
||||
If a value in `protocols` is repeated or is not a valid subprotocol name, the
|
||||
constructor throws a `SyntaxError` `DOMException`.
|
||||
|
||||
```mjs
|
||||
import { WebSocket } from 'undici'
|
||||
|
||||
const ws = new WebSocket('wss://echo.websocket.events', ['echo', 'chat'])
|
||||
```
|
||||
|
||||
To route the connection through a custom dispatcher, such as a
|
||||
[`ProxyAgent`][], pass a `WebSocketInit` object:
|
||||
|
||||
```mjs
|
||||
import { WebSocket, ProxyAgent } from 'undici'
|
||||
|
||||
const dispatcher = new ProxyAgent('http://localhost:8080')
|
||||
|
||||
const ws = new WebSocket('wss://echo.websocket.events', {
|
||||
dispatcher,
|
||||
protocols: ['echo', 'chat']
|
||||
})
|
||||
```
|
||||
|
||||
WebSocket over HTTP/2 is experimental and may change in the future. It can be
|
||||
enabled by passing a dispatcher configured with `allowH2`:
|
||||
|
||||
```mjs
|
||||
import { WebSocket, Agent } from 'undici'
|
||||
|
||||
const dispatcher = new Agent({ allowH2: true })
|
||||
|
||||
const ws = new WebSocket('wss://echo.websocket.events', {
|
||||
dispatcher,
|
||||
protocols: ['echo', 'chat']
|
||||
})
|
||||
```
|
||||
|
||||
### `websocket.close([code[, reason]])`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* `code` {number} The status code explaining why the connection is being
|
||||
closed. **Default:** `null`.
|
||||
* `reason` {string} A human-readable string explaining why the connection is
|
||||
being closed. **Default:** `''`.
|
||||
|
||||
Starts the closing handshake. The `code` is clamped to an unsigned 16-bit
|
||||
integer; when omitted, no status code is sent. The `reason` is encoded as
|
||||
UTF-8 and must not exceed 123 bytes.
|
||||
|
||||
```mjs
|
||||
ws.close(1000, 'Done')
|
||||
```
|
||||
|
||||
### `websocket.send(data)`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* `data` {string|ArrayBuffer|TypedArray|DataView|Blob} The data to enqueue for
|
||||
transmission.
|
||||
|
||||
Enqueues `data` to be transmitted to the server. String data is sent as a text
|
||||
frame; `ArrayBuffer`, any `TypedArray`, `DataView`, and `Blob` values are sent
|
||||
as binary frames. The number of bytes queued is added to
|
||||
[`websocket.bufferedAmount`][].
|
||||
|
||||
If [`websocket.readyState`][] is `CONNECTING`, an `InvalidStateError`
|
||||
`DOMException` is thrown. If the connection is closing or already closed, the
|
||||
call is silently ignored.
|
||||
|
||||
```mjs
|
||||
ws.send('a text message')
|
||||
ws.send(new Uint8Array([1, 2, 3]))
|
||||
```
|
||||
|
||||
### `websocket.readyState`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* Type: {number}
|
||||
|
||||
The current state of the connection. One of the numeric constants
|
||||
`WebSocket.CONNECTING` (`0`), `WebSocket.OPEN` (`1`),
|
||||
`WebSocket.CLOSING` (`2`), or `WebSocket.CLOSED` (`3`). This property is
|
||||
read-only.
|
||||
|
||||
### `websocket.bufferedAmount`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* Type: {number}
|
||||
|
||||
The number of bytes of data that have been queued using
|
||||
[`websocket.send()`][] but not yet transmitted to the network. This value
|
||||
resets to `0` once all queued data is sent. This property is read-only.
|
||||
|
||||
### `websocket.url`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* Type: {string}
|
||||
|
||||
The absolute URL of the WebSocket as resolved during construction. This
|
||||
property is read-only.
|
||||
|
||||
### `websocket.extensions`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* Type: {string}
|
||||
|
||||
The extensions negotiated by the server, taken from the
|
||||
`Sec-WebSocket-Extensions` response header. Empty until the connection is
|
||||
established. This property is read-only.
|
||||
|
||||
### `websocket.protocol`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* Type: {string}
|
||||
|
||||
The subprotocol selected by the server, taken from the
|
||||
`Sec-WebSocket-Protocol` response header. Empty until the connection is
|
||||
established and only set when a subprotocol was negotiated. This property is
|
||||
read-only.
|
||||
|
||||
### `websocket.binaryType`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* Type: {string} **Default:** `'blob'`.
|
||||
|
||||
Controls the type used to represent binary data delivered by the
|
||||
[`'message'`][] event. Must be either `'blob'` (binary frames are exposed as a
|
||||
`Blob`) or `'arraybuffer'` (binary frames are exposed as an `ArrayBuffer`).
|
||||
Assigning any other value resets it to `'blob'`.
|
||||
|
||||
### `websocket.onopen`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* Type: {Function|null} **Default:** `null`.
|
||||
|
||||
An event handler for the [`'open'`][] event. Setting this property replaces any
|
||||
previously registered `onopen` handler.
|
||||
|
||||
### `websocket.onerror`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* Type: {Function|null} **Default:** `null`.
|
||||
|
||||
An event handler for the [`'error'`][] event. Setting this property replaces any
|
||||
previously registered `onerror` handler.
|
||||
|
||||
### `websocket.onclose`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* Type: {Function|null} **Default:** `null`.
|
||||
|
||||
An event handler for the [`'close'`][] event. Setting this property replaces any
|
||||
previously registered `onclose` handler.
|
||||
|
||||
### `websocket.onmessage`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
* Type: {Function|null} **Default:** `null`.
|
||||
|
||||
An event handler for the [`'message'`][] event. Setting this property replaces
|
||||
any previously registered `onmessage` handler.
|
||||
|
||||
### Event: `'open'`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.21.0
|
||||
-->
|
||||
|
||||
Emitted when the WebSocket connection is established. After this event,
|
||||
[`websocket.readyState`][] is `OPEN` and data may be sent with
|
||||
[`websocket.send()`][]. The listener receives an `Event`.
|
||||
|
||||
### Event: `'message'`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
Emitted when a message is received from the server. The listener receives a
|
||||
`MessageEvent` whose `data` property is a `string` for text frames, and either a
|
||||
`Blob` or an `ArrayBuffer` for binary frames depending on
|
||||
[`websocket.binaryType`][]. The event's `origin` is the origin of
|
||||
[`websocket.url`][].
|
||||
|
||||
### Event: `'error'`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
Emitted when the connection is terminated abnormally, for example when no close
|
||||
frame was received before the underlying transport was lost. The listener
|
||||
receives an `ErrorEvent`.
|
||||
|
||||
### Event: `'close'`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
Emitted when the connection is closed. The listener receives a `CloseEvent`
|
||||
whose `code` and `reason` describe why the connection closed, and whose
|
||||
`wasClean` property is `true` only when the closing handshake completed in both
|
||||
directions.
|
||||
|
||||
## `ping(websocket[, payload])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.12.0
|
||||
-->
|
||||
|
||||
* `websocket` {WebSocket} The WebSocket to send the ping frame on.
|
||||
* `payload` {Buffer} Optional payload to include in the ping frame. Must not
|
||||
exceed 125 bytes. **Default:** `undefined`.
|
||||
|
||||
Sends a ping control frame to the server. A conforming server replies with a
|
||||
pong frame containing the same payload, which can be used to keep the connection
|
||||
alive or to verify that it is still active.
|
||||
|
||||
The frame is only sent when the connection is open; if it is connecting,
|
||||
closing, or closed, the call is a no-op. A `TypeError` is thrown if `payload`
|
||||
is not a `Buffer` or is larger than 125 bytes.
|
||||
|
||||
```mjs
|
||||
import { WebSocket, ping } from 'undici'
|
||||
|
||||
const ws = new WebSocket('wss://echo.websocket.events')
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
ping(ws)
|
||||
ping(ws, Buffer.from('hello'))
|
||||
})
|
||||
```
|
||||
|
||||
## Class: `WebSocketStream`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
`WebSocketStream` is a streams-based alternative to the event-based
|
||||
[`WebSocket`][] interface, exposing a connection as a pair of `ReadableStream`
|
||||
and `WritableStream` objects. See [MDN][MDN WebSocketStream] for background.
|
||||
|
||||
Constructing a `WebSocketStream` for the first time emits an
|
||||
`ExperimentalWarning` with the code `UNDICI-WSS`.
|
||||
|
||||
### `new WebSocketStream(url[, options])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* `url` {URL|string} The URL of the server to connect to.
|
||||
* `options` {WebSocketStreamOptions}
|
||||
* `protocols` {string|string[]} Subprotocol(s) to request the server use.
|
||||
**Default:** `[]`.
|
||||
* `signal` {AbortSignal|null} An `AbortSignal` that aborts the opening
|
||||
handshake and rejects [`webSocketStream.opened`][] and
|
||||
[`webSocketStream.closed`][] when triggered. **Default:** `null`.
|
||||
|
||||
Creates a new `WebSocketStream` and begins connecting to `url`. As with
|
||||
[`new WebSocket()`][], a repeated or invalid subprotocol causes a `SyntaxError`
|
||||
`DOMException` to be thrown.
|
||||
|
||||
```mjs
|
||||
import { WebSocketStream } from 'undici'
|
||||
|
||||
const stream = new WebSocketStream('wss://echo.websocket.events')
|
||||
const { readable, writable } = await stream.opened
|
||||
|
||||
const writer = writable.getWriter()
|
||||
await writer.write('Hello, world!')
|
||||
writer.releaseLock()
|
||||
|
||||
const reader = readable.getReader()
|
||||
const { value } = await reader.read()
|
||||
console.log('received:', value)
|
||||
```
|
||||
|
||||
### `webSocketStream.url`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Type: {string}
|
||||
|
||||
The serialized URL of the WebSocket. This property is read-only.
|
||||
|
||||
### `webSocketStream.opened`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Type: {Promise} Fulfills once the connection is established.
|
||||
|
||||
A promise that resolves with an object describing the open connection, or
|
||||
rejects if the connection could not be established. The fulfillment value has
|
||||
the following shape:
|
||||
|
||||
* `extensions` {string} The negotiated extensions.
|
||||
* `protocol` {string} The negotiated subprotocol.
|
||||
* `readable` {ReadableStream} A stream of incoming messages. Text frames are
|
||||
delivered as strings and binary frames as `Uint8Array` chunks.
|
||||
* `writable` {WritableStream} A stream for sending messages. Writing a string
|
||||
sends a text frame; writing a `BufferSource` sends a binary frame.
|
||||
|
||||
### `webSocketStream.closed`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Type: {Promise} Fulfills once the connection is closed cleanly.
|
||||
|
||||
A promise that resolves with a `WebSocketCloseInfo` object when the connection
|
||||
closes cleanly, or rejects with a [`WebSocketError`][] when it closes
|
||||
abnormally. The fulfillment value has the following shape:
|
||||
|
||||
* `closeCode` {number} The status code the connection was closed with.
|
||||
* `reason` {string} The reason the connection was closed with.
|
||||
|
||||
### `webSocketStream.close([closeInfo])`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* `closeInfo` {Object}
|
||||
* `closeCode` {number} The status code to close with.
|
||||
* `reason` {string} The reason to close with. **Default:** `''`.
|
||||
|
||||
Starts the closing handshake. When `closeInfo` is omitted, the connection is
|
||||
closed without a status code.
|
||||
|
||||
## Class: `WebSocketError`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Extends: {DOMException}
|
||||
|
||||
An error type used by [`WebSocketStream`][] to report close information. It is
|
||||
used to reject [`webSocketStream.closed`][] when the connection closes
|
||||
abnormally, and may be passed to a stream's abort or cancel operations to close
|
||||
the connection with a specific code and reason.
|
||||
|
||||
### `new WebSocketError([message[, init]])`
|
||||
|
||||
<!-- YAML
|
||||
added: v7.0.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* `message` {string} A human-readable description of the error.
|
||||
**Default:** `''`.
|
||||
* `init` {Object}
|
||||
* `closeCode` {number} The status code to associate with the error.
|
||||
* `reason` {string} The reason to associate with the error.
|
||||
**Default:** `''`.
|
||||
|
||||
Creates a new `WebSocketError`. If `reason` is non-empty but no `closeCode` is
|
||||
given, `closeCode` defaults to `1000` ("Normal Closure"). The `closeCode` and
|
||||
`reason` are validated, so an out-of-range code or an over-long reason throws.
|
||||
|
||||
### `webSocketError.closeCode`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Type: {number|null}
|
||||
|
||||
The status code associated with the error, or `null` if none was set. This
|
||||
property is read-only.
|
||||
|
||||
### `webSocketError.reason`
|
||||
|
||||
<!-- YAML
|
||||
added: v5.15.0
|
||||
-->
|
||||
|
||||
> Stability: 1 - Experimental
|
||||
|
||||
* Type: {string}
|
||||
|
||||
The reason associated with the error. This property is read-only.
|
||||
|
||||
## Read more
|
||||
|
||||
* [MDN: WebSocket][]
|
||||
* [RFC 6455][]
|
||||
* [WHATWG WebSocket specification][]
|
||||
|
||||
[MDN WebSocketStream]: https://developer.mozilla.org/docs/Web/API/WebSocketStream
|
||||
[MDN: WebSocket]: https://developer.mozilla.org/docs/Web/API/WebSocket
|
||||
[RFC 6455]: https://www.rfc-editor.org/rfc/rfc6455
|
||||
[WHATWG WebSocket specification]: https://websockets.spec.whatwg.org/
|
||||
[`'close'`]: #event-close
|
||||
[`'error'`]: #event-error
|
||||
[`'message'`]: #event-message
|
||||
[`'open'`]: #event-open
|
||||
[`ProxyAgent`]: ProxyAgent.md#class-proxyagent
|
||||
[`WebSocket`]: #class-websocket
|
||||
[`WebSocketError`]: #class-websocketerror
|
||||
[`WebSocketStream`]: #class-websocketstream
|
||||
[`new WebSocket()`]: #new-websocketurl-protocols
|
||||
[`ping()`]: #pingwebsocket-payload
|
||||
[`webSocketStream.closed`]: #websocketstreamclosed
|
||||
[`webSocketStream.opened`]: #websocketstreamopened
|
||||
[`websocket.bufferedAmount`]: #websocketbufferedamount
|
||||
[`websocket.binaryType`]: #websocketbinarytype
|
||||
[`websocket.readyState`]: #websocketreadystate
|
||||
[`websocket.send()`]: #websocketsenddata
|
||||
[`websocket.url`]: #websocketurl
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
# Client Lifecycle
|
||||
|
||||
<!--type=misc-->
|
||||
|
||||
A {Client} can be understood as a state machine. As requests are dispatched,
|
||||
processed, and drained, the client moves through a well-defined set of states
|
||||
until it is eventually destroyed. This document describes those states and the
|
||||
transitions between them.
|
||||
|
||||
The {Client} class is not literally implemented as a state machine, so the
|
||||
actual execution may deviate slightly from what is described here. Treat this
|
||||
guide as a conceptual model for reasoning about a client's behavior rather than
|
||||
as a formal specification.
|
||||
|
||||
## State transition overview
|
||||
|
||||
* A {Client} begins in the **idle** state with no socket connection and no
|
||||
queued requests.
|
||||
* The *connect* transition moves the {Client} to the **pending** state, where
|
||||
requests can be queued before they are processed.
|
||||
* Calling [`client.close()`][] or [`client.destroy()`][] moves the {Client}
|
||||
to the **destroyed** state. Because there are no queued requests in this
|
||||
state, *close* transitions straight to **destroyed**.
|
||||
* The **pending** state indicates that the underlying socket connection has been
|
||||
established and that requests are queueing.
|
||||
* The *process* transition moves the {Client} to the **processing** state,
|
||||
where requests are processed.
|
||||
* If requests are queued, *close* transitions to **processing**; otherwise it
|
||||
transitions to **destroyed**.
|
||||
* The *destroy* transition moves the {Client} to **destroyed**.
|
||||
* The **processing** state initializes to the **processing.running** sub-state.
|
||||
* If the current request body requires draining, *needDrain* moves the
|
||||
{Client} into the **processing.busy** sub-state, which returns to
|
||||
**processing.running** once *drainComplete* fires.
|
||||
* After all queued requests complete, *keepalive* moves the {Client} back to
|
||||
the **pending** state. If no requests are queued before the socket times
|
||||
out, the {Client} transitions to **idle**.
|
||||
* If *close* is fired while the {Client} still has queued requests, the
|
||||
{Client} transitions to **processing.closing**, where it completes all
|
||||
outstanding requests before firing *done*.
|
||||
* The *done* transition moves the {Client} gracefully to **destroyed**.
|
||||
* At any time, *destroy* moves the {Client} from **processing** to
|
||||
**destroyed**, aborting any queued requests.
|
||||
* The **destroyed** state is final; the {Client} is no longer functional.
|
||||
|
||||
A state diagram representing a {Client} instance:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> idle
|
||||
idle --> pending : connect
|
||||
idle --> destroyed : destroy/close
|
||||
|
||||
pending --> idle : timeout
|
||||
pending --> destroyed : destroy
|
||||
|
||||
state close_fork <<fork>>
|
||||
pending --> close_fork : close
|
||||
close_fork --> processing
|
||||
close_fork --> destroyed
|
||||
|
||||
pending --> processing : process
|
||||
|
||||
processing --> pending : keepalive
|
||||
processing --> destroyed : done
|
||||
processing --> destroyed : destroy
|
||||
|
||||
destroyed --> [*]
|
||||
|
||||
state processing {
|
||||
[*] --> running
|
||||
running --> closing : close
|
||||
running --> busy : needDrain
|
||||
busy --> running : drainComplete
|
||||
running --> [*] : keepalive
|
||||
closing --> [*] : done
|
||||
}
|
||||
```
|
||||
|
||||
## State details
|
||||
|
||||
### idle
|
||||
|
||||
**idle** is the initial state of a {Client} instance. Although an `origin` is
|
||||
required to construct a {Client}, the underlying socket connection is not
|
||||
established until a request is queued through [`client.dispatch()`][]. Calling
|
||||
`client.dispatch()` directly, or through one of its higher-level wrappers
|
||||
([`client.connect()`][], [`client.pipeline()`][], [`client.request()`][],
|
||||
[`client.stream()`][], or [`client.upgrade()`][]), moves the {Client} from
|
||||
**idle** to **pending**, and then usually directly on to **processing**.
|
||||
|
||||
Calling [`client.close()`][] or [`client.destroy()`][] in this state moves the
|
||||
{Client} straight to **destroyed**, since there are no queued requests.
|
||||
|
||||
### pending
|
||||
|
||||
**pending** signifies a connected but non-processing {Client}. On entering this
|
||||
state, the {Client} establishes a socket connection and emits the
|
||||
[`'connect'`][] event, signalling that a connection to the `origin` supplied at
|
||||
construction time has been established. The internal queue starts empty, and
|
||||
requests can begin queueing.
|
||||
|
||||
Calling [`client.close()`][] with queued requests moves the {Client} to the
|
||||
**processing** state; without queued requests it moves to **destroyed**.
|
||||
|
||||
Calling [`client.destroy()`][] moves the {Client} directly to **destroyed**,
|
||||
regardless of any queued requests.
|
||||
|
||||
### processing
|
||||
|
||||
**processing** is itself a sub-state machine that initializes to the
|
||||
**processing.running** sub-state. [`client.dispatch()`][], [`client.close()`][],
|
||||
and [`client.destroy()`][] can all be called while the {Client} is in this
|
||||
state. `client.dispatch()` queues additional requests while existing ones
|
||||
continue to be processed, `client.close()` moves the {Client} to the
|
||||
**processing.closing** sub-state, and `client.destroy()` moves it to
|
||||
**destroyed**.
|
||||
|
||||
#### running
|
||||
|
||||
In **processing.running**, queued requests are processed in FIFO order. If a
|
||||
request body requires draining, *needDrain* moves the {Client} to the
|
||||
**processing.busy** sub-state. The *close* transition moves the {Client} to the
|
||||
**processing.closing** sub-state. When all queued requests have completed and
|
||||
neither [`client.close()`][] nor [`client.destroy()`][] has been called, the
|
||||
*keepalive* transition moves the {Client} back to the **pending** state. There
|
||||
the {Client} waits for the socket connection to time out; once it does, the
|
||||
{Client} returns to the **idle** state.
|
||||
|
||||
#### busy
|
||||
|
||||
This sub-state is entered only when a request body is a {stream.Readable} that
|
||||
requires draining. The {Client} cannot process additional requests while in
|
||||
this state and waits until the current request body has been fully drained
|
||||
before returning to **processing.running**.
|
||||
|
||||
#### closing
|
||||
|
||||
This sub-state is entered only when a {Client} has queued requests and
|
||||
[`client.close()`][] is called. The {Client} continues to process requests as
|
||||
usual, except that no additional requests may be queued. Once all queued
|
||||
requests have been processed, *done* fires and the {Client} moves gracefully to
|
||||
**destroyed** without an error.
|
||||
|
||||
### destroyed
|
||||
|
||||
**destroyed** is the final state of a {Client} instance. Once in this state, the
|
||||
{Client} is no longer functional, and calling any further {Client} method
|
||||
rejects or fails with a {ClientDestroyedError}.
|
||||
|
||||
[`'connect'`]: Client.md#event-connect
|
||||
[`client.close()`]: Client.md#clientclosecallback
|
||||
[`client.connect()`]: Client.md#clientconnectoptions-callback
|
||||
[`client.destroy()`]: Client.md#clientdestroyerror-callback
|
||||
[`client.dispatch()`]: Client.md#clientdispatchoptions-handlers
|
||||
[`client.pipeline()`]: Client.md#clientpipelineoptions-handler
|
||||
[`client.request()`]: Client.md#clientrequestoptions-callback
|
||||
[`client.stream()`]: Client.md#clientstreamoptions-factory-callback
|
||||
[`client.upgrade()`]: Client.md#clientupgradeoptions-callback
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# Client certificate
|
||||
|
||||
<!--type=misc-->
|
||||
|
||||
Client certificate authentication can be configured with the `Client`, the required options are passed along through the `connect` option.
|
||||
|
||||
The client certificates must be signed by a trusted CA. The Node.js default is to trust the well-known CAs curated by Mozilla.
|
||||
|
||||
Setting the server option `requestCert: true` tells the server to request the client certificate.
|
||||
|
||||
The server option `rejectUnauthorized: false` allows us to handle any invalid certificate errors in client code. The `authorized` property on the socket of the incoming request will show if the client certificate was valid. The `authorizationError` property will give the reason if the certificate was not valid.
|
||||
|
||||
### Client Certificate Authentication
|
||||
|
||||
```js
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const { createServer } = require('node:https')
|
||||
const { Client } = require('undici')
|
||||
|
||||
const serverOptions = {
|
||||
ca: [
|
||||
readFileSync(join(__dirname, 'client-ca-crt.pem'), 'utf8')
|
||||
],
|
||||
key: readFileSync(join(__dirname, 'server-key.pem'), 'utf8'),
|
||||
cert: readFileSync(join(__dirname, 'server-crt.pem'), 'utf8'),
|
||||
requestCert: true,
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
|
||||
const server = createServer(serverOptions, (req, res) => {
|
||||
// true if client cert is valid
|
||||
if(req.client.authorized === true) {
|
||||
console.log('valid')
|
||||
} else {
|
||||
console.error(req.client.authorizationError)
|
||||
}
|
||||
res.end()
|
||||
})
|
||||
|
||||
server.listen(0, function () {
|
||||
const tls = {
|
||||
ca: [
|
||||
readFileSync(join(__dirname, 'server-ca-crt.pem'), 'utf8')
|
||||
],
|
||||
key: readFileSync(join(__dirname, 'client-key.pem'), 'utf8'),
|
||||
cert: readFileSync(join(__dirname, 'client-crt.pem'), 'utf8'),
|
||||
rejectUnauthorized: false,
|
||||
servername: 'agent1'
|
||||
}
|
||||
const client = new Client(`https://localhost:${server.address().port}`, {
|
||||
connect: tls
|
||||
})
|
||||
|
||||
client.request({
|
||||
path: '/',
|
||||
method: 'GET'
|
||||
}, (err, { body }) => {
|
||||
body.on('data', (buf) => {})
|
||||
body.on('end', () => {
|
||||
client.close()
|
||||
server.close()
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
# Crawling
|
||||
|
||||
<!--type=misc-->
|
||||
|
||||
[RFC 9309](https://datatracker.ietf.org/doc/html/rfc9309) defines crawlers as automated clients.
|
||||
|
||||
Some web servers may reject requests that omit the `User-Agent` header or that use common defaults such as `'curl/7.79.1'`.
|
||||
|
||||
In **undici**, the default user agent is `'undici'`. Since undici is integrated into Node.js core as the implementation of `fetch()`, requests made via `fetch()` use `'node'` as the default user agent.
|
||||
|
||||
It is recommended to specify a **custom `User-Agent` header** when implementing crawlers. Providing a descriptive user agent allows servers to correctly identify the client and reduces the likelihood of requests being denied.
|
||||
|
||||
A user agent string should include sufficient detail to identify the crawler and provide contact information. For example:
|
||||
|
||||
```text
|
||||
AcmeCo Crawler - acme.co - contact@acme.co
|
||||
```
|
||||
|
||||
When adding contact details, avoid using personal identifiers such as your own name or a private email address—especially in a professional or employment context. Instead, use a role-based or organizational contact (e.g., crawler-team@company.com) to protect individual privacy while still enabling communication.
|
||||
|
||||
If a crawler behaves unexpectedly—for example, due to misconfiguration or implementation errors—server administrators can use the information in the user agent to contact the operator and coordinate an appropriate resolution.
|
||||
|
||||
The `User-Agent` header can be set on individual requests or applied globally by configuring a custom dispatcher.
|
||||
|
||||
**Example: setting a `User-Agent` per request**
|
||||
|
||||
```js
|
||||
import { fetch } from 'undici'
|
||||
|
||||
const headers = {
|
||||
'User-Agent': 'AcmeCo Crawler - acme.co - contact@acme.co'
|
||||
}
|
||||
|
||||
const res = await fetch('https://example.com', { headers })
|
||||
```
|
||||
|
||||
## Best Practices for Crawlers
|
||||
|
||||
When developing a crawler, the following practices are recommended in addition to setting a descriptive `User-Agent` header:
|
||||
|
||||
* **Respect `robots.txt`**
|
||||
Follow the directives defined in the target site’s `robots.txt` file, including disallowed paths and optional crawl-delay settings (see [W3C guidelines](https://www.w3.org/wiki/Write_Web_Crawler)).
|
||||
|
||||
* **Rate limiting**
|
||||
Regulate request frequency to avoid imposing excessive load on servers. Introduce delays between requests or limit the number of concurrent requests. The W3C suggests at least one second between requests.
|
||||
|
||||
* **Error handling**
|
||||
Implement retry logic with exponential backoff for transient failures, and stop requests when persistent errors occur (e.g., HTTP 403 or 429).
|
||||
|
||||
* **Monitoring and logging**
|
||||
Track request volume, response codes, and error rates to detect misbehavior and address issues proactively.
|
||||
|
||||
* **Contact information**
|
||||
Always include valid and current contact details in the `User-Agent` string so that administrators can reach the crawler operator if necessary.
|
||||
|
||||
## References and Further Reading
|
||||
|
||||
* [RFC 9309: The Robots Exclusion Protocol](https://datatracker.ietf.org/doc/html/rfc9309)
|
||||
* [W3C Wiki: Write Web Crawler](https://www.w3.org/wiki/Write_Web_Crawler)
|
||||
* [Ethical Web Crawling (WWW 2010 Conference Paper)](https://archives.iw3c2.org/www2010/proceedings/www/p1101.pdf)
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
# Migrating from Undici 7 to 8
|
||||
|
||||
<!--type=misc-->
|
||||
|
||||
This guide covers the changes you are most likely to hit when upgrading an
|
||||
application or library from Undici v7 to v8.
|
||||
|
||||
## Before you upgrade
|
||||
|
||||
- Make sure your runtime is Node.js `>= 22.19.0`.
|
||||
- If you have custom dispatchers, interceptors, or handlers, review the
|
||||
handler API changes before updating.
|
||||
- If you rely on HTTP/1.1-only behavior, plan to set `allowH2: false`
|
||||
explicitly.
|
||||
|
||||
## 1. Update your Node.js version
|
||||
|
||||
Undici v8 requires Node.js `>= 22.19.0`.
|
||||
|
||||
If you are still on Node.js 20 or an older Node.js 22 release, upgrade Node.js
|
||||
first:
|
||||
|
||||
```bash
|
||||
node -v
|
||||
```
|
||||
|
||||
If that command prints a version lower than `v22.19.0`, upgrade Node.js before
|
||||
installing Undici v8.
|
||||
|
||||
## 2. Migrate custom dispatcher handlers to the v2 API
|
||||
|
||||
Undici v8 uses the newer dispatcher handler API consistently.
|
||||
|
||||
If you implemented custom dispatchers, interceptors, or wrappers around
|
||||
`dispatch()`, update legacy callbacks such as `onConnect`, `onHeaders`, and
|
||||
`onComplete` to the newer callback names.
|
||||
|
||||
### Old handler callbacks vs. v8 callbacks
|
||||
|
||||
| Undici 7 style | Undici 8 style |
|
||||
|---|---|
|
||||
| `onConnect(abort, context)` | `onRequestStart(controller, context)` |
|
||||
| `onHeaders(statusCode, rawHeaders, resume, statusText)` | `onResponseStart(controller, statusCode, headers, statusText)` |
|
||||
| `onData(chunk)` | `onResponseData(controller, chunk)` |
|
||||
| `onComplete(trailers)` | `onResponseEnd(controller, trailers)` |
|
||||
| `onError(err)` | `onResponseError(controller, err)` |
|
||||
| `onUpgrade(statusCode, rawHeaders, socket)` | `onRequestUpgrade(controller, statusCode, headers, socket)` |
|
||||
|
||||
### Example
|
||||
|
||||
Before:
|
||||
|
||||
```js
|
||||
client.dispatch(options, {
|
||||
onConnect (abort) {
|
||||
this.abort = abort
|
||||
},
|
||||
onHeaders (statusCode, headers, resume) {
|
||||
this.resume = resume
|
||||
return true
|
||||
},
|
||||
onData (chunk) {
|
||||
chunks.push(chunk)
|
||||
return true
|
||||
},
|
||||
onComplete (trailers) {
|
||||
console.log(trailers)
|
||||
},
|
||||
onError (err) {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```js
|
||||
client.dispatch(options, {
|
||||
onRequestStart (controller) {
|
||||
this.controller = controller
|
||||
},
|
||||
onResponseStart (controller, statusCode, headers, statusText) {
|
||||
console.log(statusCode, statusText, headers)
|
||||
},
|
||||
onResponseData (controller, chunk) {
|
||||
chunks.push(chunk)
|
||||
},
|
||||
onResponseEnd (controller, trailers) {
|
||||
console.log(trailers)
|
||||
},
|
||||
onResponseError (controller, err) {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Pause, resume, and abort now go through the controller
|
||||
|
||||
In Undici v7, legacy handlers could return `false` or keep references to
|
||||
`abort()` and `resume()` callbacks. In Undici v8, use the controller instead:
|
||||
|
||||
```js
|
||||
onRequestStart (controller) {
|
||||
this.controller = controller
|
||||
}
|
||||
|
||||
onResponseData (controller, chunk) {
|
||||
controller.pause()
|
||||
setImmediate(() => controller.resume())
|
||||
}
|
||||
|
||||
onResponseError (controller, err) {
|
||||
controller.abort(err)
|
||||
}
|
||||
```
|
||||
|
||||
### Raw headers and trailers moved to the controller
|
||||
|
||||
If you need the raw header arrays, read them from the controller:
|
||||
|
||||
- `controller.rawHeaders`
|
||||
- `controller.rawTrailers`
|
||||
|
||||
## 3. Update `onBodySent()` handlers
|
||||
|
||||
If you implemented `onBodySent()`, note that its signature changed.
|
||||
|
||||
Before, handlers received counters:
|
||||
|
||||
```js
|
||||
onBodySent (chunkSize, totalBytesSent) {}
|
||||
```
|
||||
|
||||
In Undici v8, handlers receive the actual chunk:
|
||||
|
||||
```js
|
||||
onBodySent (chunk) {}
|
||||
```
|
||||
|
||||
If you need a notification that the whole body has been sent, use
|
||||
`onRequestSent()`:
|
||||
|
||||
```js
|
||||
onRequestSent () {
|
||||
console.log('request body fully sent')
|
||||
}
|
||||
```
|
||||
|
||||
## 4. If you need HTTP/1.1 only, disable HTTP/2 explicitly
|
||||
|
||||
Undici v8 enables HTTP/2 by default when a TLS server negotiates it via ALPN.
|
||||
|
||||
If your application depends on HTTP/1.1-specific behavior, set `allowH2: false`
|
||||
explicitly.
|
||||
|
||||
Before:
|
||||
|
||||
```js
|
||||
const client = new Client('https://example.com')
|
||||
```
|
||||
|
||||
After, to keep HTTP/1.1 only:
|
||||
|
||||
```js
|
||||
const client = new Client('https://example.com', {
|
||||
allowH2: false
|
||||
})
|
||||
```
|
||||
|
||||
The same applies when you configure an `Agent`:
|
||||
|
||||
```js
|
||||
const agent = new Agent({
|
||||
allowH2: false
|
||||
})
|
||||
```
|
||||
|
||||
## 5. Use real `Blob` and `File` instances
|
||||
|
||||
Undici v8 no longer accepts fake Blob-like values that only imitate `Blob` or
|
||||
`File` via properties such as `Symbol.toStringTag`.
|
||||
|
||||
If you were passing custom objects that looked like `Blob`s, replace them with
|
||||
actual `Blob` or `File` instances:
|
||||
|
||||
```js
|
||||
const body = new Blob(['hello'])
|
||||
```
|
||||
|
||||
## 6. Avoid depending on the internal global dispatcher symbol
|
||||
|
||||
`setGlobalDispatcher()` and `getGlobalDispatcher()` remain the public APIs and
|
||||
should continue to be used.
|
||||
|
||||
Internally, Undici v8 stores its dispatcher under
|
||||
`Symbol.for('undici.globalDispatcher.2')` and mirrors a v1-compatible wrapper
|
||||
for legacy consumers such as Node.js built-in `fetch`.
|
||||
|
||||
If your code was reading or writing `Symbol.for('undici.globalDispatcher.1')`
|
||||
directly, migrate to the public APIs instead:
|
||||
|
||||
```js
|
||||
import { setGlobalDispatcher, getGlobalDispatcher, Agent } from 'undici'
|
||||
|
||||
setGlobalDispatcher(new Agent())
|
||||
const dispatcher = getGlobalDispatcher()
|
||||
```
|
||||
|
||||
If you must expose a dispatcher to legacy v1 handler consumers, wrap it with
|
||||
`Dispatcher1Wrapper`:
|
||||
|
||||
```js
|
||||
import { Agent, Dispatcher1Wrapper } from 'undici'
|
||||
|
||||
const legacyCompatibleDispatcher = new Dispatcher1Wrapper(new Agent())
|
||||
```
|
||||
|
||||
## 7. Verify the upgrade
|
||||
|
||||
After moving to Undici v8, it is worth checking these paths in your test suite:
|
||||
|
||||
- requests that use a custom `dispatcher`
|
||||
- `setGlobalDispatcher()` behavior
|
||||
- any custom interceptor or retry handler
|
||||
- uploads that use `Blob`, `File`, or `FormData`
|
||||
- integrations that depend on HTTP/1.1-only behavior
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Dispatcher](../api/Dispatcher.md)
|
||||
- [Client](../api/Client.md)
|
||||
- [Global Installation](../api/GlobalInstallation.md)
|
||||
- [Undici Module vs. Node.js Built-in Fetch](./undici-vs-builtin-fetch.md)
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
# Mocking Request
|
||||
|
||||
<!--type=misc-->
|
||||
|
||||
Undici has its own mocking [utility](../api/MockAgent.md). It allow us to intercept undici HTTP requests and return mocked values instead. It can be useful for testing purposes.
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
// bank.mjs
|
||||
import { request } from 'undici'
|
||||
|
||||
export async function bankTransfer(recipient, amount) {
|
||||
const { body } = await request('http://localhost:3000/bank-transfer',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-TOKEN-SECRET': 'SuperSecretToken',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipient,
|
||||
amount
|
||||
})
|
||||
}
|
||||
)
|
||||
return await body.json()
|
||||
}
|
||||
```
|
||||
|
||||
And this is what the test file looks like:
|
||||
|
||||
```js
|
||||
// index.test.mjs
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { MockAgent, setGlobalDispatcher, } from 'undici'
|
||||
import { bankTransfer } from './bank.mjs'
|
||||
|
||||
const mockAgent = new MockAgent();
|
||||
|
||||
setGlobalDispatcher(mockAgent);
|
||||
|
||||
// Provide the base url to the request
|
||||
const mockPool = mockAgent.get('http://localhost:3000');
|
||||
|
||||
// intercept the request
|
||||
mockPool.intercept({
|
||||
path: '/bank-transfer',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-TOKEN-SECRET': 'SuperSecretToken',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipient: '1234567890',
|
||||
amount: '100'
|
||||
})
|
||||
}).reply(200, {
|
||||
message: 'transaction processed'
|
||||
})
|
||||
|
||||
const success = await bankTransfer('1234567890', '100')
|
||||
|
||||
assert.deepEqual(success, { message: 'transaction processed' })
|
||||
|
||||
// if you dont want to check whether the body or the headers contain the same value
|
||||
// just remove it from interceptor
|
||||
mockPool.intercept({
|
||||
path: '/bank-transfer',
|
||||
method: 'POST',
|
||||
}).reply(400, {
|
||||
message: 'bank account not found'
|
||||
})
|
||||
|
||||
const badRequest = await bankTransfer('1234567890', '100')
|
||||
|
||||
assert.deepEqual(badRequest, { message: 'bank account not found' })
|
||||
```
|
||||
|
||||
Explore other MockAgent functionality [here](../api/MockAgent.md)
|
||||
|
||||
## Access agent call history
|
||||
|
||||
Using a MockAgent also allows you to make assertions on the configuration used to make your request in your application.
|
||||
|
||||
Here is an example :
|
||||
|
||||
```js
|
||||
// index.test.mjs
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { MockAgent, setGlobalDispatcher, fetch } from 'undici'
|
||||
import { app } from './app.mjs'
|
||||
|
||||
// given an application server running on http://localhost:3000
|
||||
await app.start()
|
||||
|
||||
// enable call history at instantiation
|
||||
const mockAgent = new MockAgent({ enableCallHistory: true })
|
||||
// or after instantiation
|
||||
mockAgent.enableCallHistory()
|
||||
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
// this call is made (not intercepted)
|
||||
await fetch(`http://localhost:3000/endpoint?query='hello'`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ data: '' })
|
||||
})
|
||||
|
||||
// access to the call history of the MockAgent (which register every call made intercepted or not)
|
||||
assert.ok(mockAgent.getCallHistory()?.calls().length === 1)
|
||||
assert.strictEqual(mockAgent.getCallHistory()?.firstCall()?.fullUrl, `http://localhost:3000/endpoint?query='hello'`)
|
||||
assert.strictEqual(mockAgent.getCallHistory()?.firstCall()?.body, JSON.stringify({ data: '' }))
|
||||
assert.deepStrictEqual(mockAgent.getCallHistory()?.firstCall()?.searchParams, { query: 'hello' })
|
||||
assert.strictEqual(mockAgent.getCallHistory()?.firstCall()?.port, '3000')
|
||||
assert.strictEqual(mockAgent.getCallHistory()?.firstCall()?.host, 'localhost:3000')
|
||||
assert.strictEqual(mockAgent.getCallHistory()?.firstCall()?.method, 'POST')
|
||||
assert.strictEqual(mockAgent.getCallHistory()?.firstCall()?.path, '/endpoint')
|
||||
assert.deepStrictEqual(mockAgent.getCallHistory()?.firstCall()?.headers, { 'content-type': 'application/json' })
|
||||
|
||||
// clear all call history logs
|
||||
mockAgent.clearCallHistory()
|
||||
|
||||
assert.ok(mockAgent.getCallHistory()?.calls().length === 0)
|
||||
```
|
||||
|
||||
Calling `mockAgent.close()` will automatically clear and delete every call history for you.
|
||||
|
||||
Explore other MockAgent functionality [here](../api/MockAgent.md)
|
||||
|
||||
Explore other MockCallHistory functionality [here](../api/MockCallHistory.md)
|
||||
|
||||
Explore other MockCallHistoryLog functionality [here](../api/MockCallHistoryLog.md)
|
||||
|
||||
## Debug Mock Value
|
||||
|
||||
When the interceptor and the request options are not the same, undici will automatically make a real HTTP request. To prevent real requests from being made, use `mockAgent.disableNetConnect()`:
|
||||
|
||||
```js
|
||||
const mockAgent = new MockAgent();
|
||||
|
||||
setGlobalDispatcher(mockAgent);
|
||||
mockAgent.disableNetConnect()
|
||||
|
||||
// Provide the base url to the request
|
||||
const mockPool = mockAgent.get('http://localhost:3000');
|
||||
|
||||
mockPool.intercept({
|
||||
path: '/bank-transfer',
|
||||
method: 'POST',
|
||||
}).reply(200, {
|
||||
message: 'transaction processed'
|
||||
})
|
||||
|
||||
const badRequest = await bankTransfer('1234567890', '100')
|
||||
// Will throw an error
|
||||
// MockNotMatchedError: Mock dispatch not matched for path '/bank-transfer':
|
||||
// subsequent request to origin http://localhost:3000 was not allowed (net.connect disabled)
|
||||
```
|
||||
|
||||
## Reply with data based on request
|
||||
|
||||
If the mocked response needs to be dynamically derived from the request parameters, you can provide a function instead of an object to `reply`:
|
||||
|
||||
```js
|
||||
mockPool.intercept({
|
||||
path: '/bank-transfer',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-TOKEN-SECRET': 'SuperSecretToken',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipient: '1234567890',
|
||||
amount: '100'
|
||||
})
|
||||
}).reply(200, (opts) => {
|
||||
// do something with opts
|
||||
|
||||
return { message: 'transaction processed' }
|
||||
})
|
||||
```
|
||||
|
||||
in this case opts will be
|
||||
|
||||
```js
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'X-TOKEN-SECRET': 'SuperSecretToken' },
|
||||
body: '{"recipient":"1234567890","amount":"100"}',
|
||||
origin: 'http://localhost:3000',
|
||||
path: '/bank-transfer'
|
||||
}
|
||||
```
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# Connecting through a proxy
|
||||
|
||||
<!--type=misc-->
|
||||
|
||||
Connecting through a proxy is possible by:
|
||||
|
||||
- Using {ProxyAgent}.
|
||||
- Configuring `Client` or `Pool` constructor.
|
||||
|
||||
The proxy url should be passed to the `Client` or `Pool` constructor, while the upstream server url
|
||||
should be added to every request call in the `path`.
|
||||
For instance, if you need to send a request to the `/hello` route of your upstream server,
|
||||
the `path` should be `path: 'http://upstream.server:port/hello?foo=bar'`.
|
||||
|
||||
If you proxy requires basic authentication, you can send it via the `proxy-authorization` header.
|
||||
|
||||
### Connect without authentication
|
||||
|
||||
```js
|
||||
import { Client } from 'undici'
|
||||
import { createServer } from 'http'
|
||||
import { createProxy } from 'proxy'
|
||||
|
||||
const server = await buildServer()
|
||||
const proxyServer = await buildProxy()
|
||||
|
||||
const serverUrl = `http://localhost:${server.address().port}`
|
||||
const proxyUrl = `http://localhost:${proxyServer.address().port}`
|
||||
|
||||
server.on('request', (req, res) => {
|
||||
console.log(req.url) // '/hello?foo=bar'
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify({ hello: 'world' }))
|
||||
})
|
||||
|
||||
const client = new Client(proxyUrl)
|
||||
|
||||
const response = await client.request({
|
||||
method: 'GET',
|
||||
path: serverUrl + '/hello?foo=bar'
|
||||
})
|
||||
|
||||
response.body.setEncoding('utf8')
|
||||
let data = ''
|
||||
for await (const chunk of response.body) {
|
||||
data += chunk
|
||||
}
|
||||
console.log(response.statusCode) // 200
|
||||
console.log(JSON.parse(data)) // { hello: 'world' }
|
||||
|
||||
server.close()
|
||||
proxyServer.close()
|
||||
client.close()
|
||||
|
||||
function buildServer () {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.listen(0, () => resolve(server))
|
||||
})
|
||||
}
|
||||
|
||||
function buildProxy () {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createProxy(createServer())
|
||||
server.listen(0, () => resolve(server))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Connect with authentication
|
||||
|
||||
```js
|
||||
import { Client } from 'undici'
|
||||
import { createServer } from 'http'
|
||||
import { createProxy } from 'proxy'
|
||||
|
||||
const server = await buildServer()
|
||||
const proxyServer = await buildProxy()
|
||||
|
||||
const serverUrl = `http://localhost:${server.address().port}`
|
||||
const proxyUrl = `http://localhost:${proxyServer.address().port}`
|
||||
|
||||
proxyServer.authenticate = function (req) {
|
||||
return req.headers['proxy-authorization'] === `Basic ${Buffer.from('user:pass').toString('base64')}`
|
||||
}
|
||||
|
||||
server.on('request', (req, res) => {
|
||||
console.log(req.url) // '/hello?foo=bar'
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify({ hello: 'world' }))
|
||||
})
|
||||
|
||||
const client = new Client(proxyUrl)
|
||||
|
||||
const response = await client.request({
|
||||
method: 'GET',
|
||||
path: serverUrl + '/hello?foo=bar',
|
||||
headers: {
|
||||
'proxy-authorization': `Basic ${Buffer.from('user:pass').toString('base64')}`
|
||||
}
|
||||
})
|
||||
|
||||
response.body.setEncoding('utf8')
|
||||
let data = ''
|
||||
for await (const chunk of response.body) {
|
||||
data += chunk
|
||||
}
|
||||
console.log(response.statusCode) // 200
|
||||
console.log(JSON.parse(data)) // { hello: 'world' }
|
||||
|
||||
server.close()
|
||||
proxyServer.close()
|
||||
client.close()
|
||||
|
||||
function buildServer () {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.listen(0, () => resolve(server))
|
||||
})
|
||||
}
|
||||
|
||||
function buildProxy () {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createProxy(createServer())
|
||||
server.listen(0, () => resolve(server))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
# Undici Module vs. Node.js Built-in Fetch
|
||||
|
||||
<!--type=misc-->
|
||||
|
||||
Node.js has shipped a built-in `fetch()` implementation powered by undici since
|
||||
Node.js v18. This guide explains the relationship between the `undici` npm
|
||||
package and the built-in `fetch`, and when you should install one versus relying
|
||||
on the other.
|
||||
|
||||
## Background
|
||||
|
||||
The `fetch()`, `Request`, `Response`, `Headers`, and `FormData` globals in
|
||||
Node.js v18+ are provided by a version of undici that is bundled into Node.js
|
||||
itself. You can check which version is bundled with:
|
||||
|
||||
```js
|
||||
console.log(process.versions.undici); // e.g., "7.5.0"
|
||||
```
|
||||
|
||||
When you install undici from npm, you get the full library with all of its
|
||||
additional APIs, and potentially a newer release than what your Node.js version
|
||||
bundles.
|
||||
|
||||
## Keep `fetch` and `FormData` from the same implementation
|
||||
|
||||
When you send a `FormData` body, keep `fetch` and `FormData` together from the
|
||||
same implementation.
|
||||
|
||||
Use one of these patterns:
|
||||
|
||||
### Built-in globals
|
||||
|
||||
```js
|
||||
const body = new FormData()
|
||||
body.set('name', 'some')
|
||||
body.set('someOtherProperty', '8000')
|
||||
|
||||
await fetch('https://example.com', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
```
|
||||
|
||||
### `undici` module imports
|
||||
|
||||
```js
|
||||
import { fetch, FormData } from 'undici'
|
||||
|
||||
const body = new FormData()
|
||||
body.set('name', 'some')
|
||||
body.set('someOtherProperty', '8000')
|
||||
|
||||
await fetch('https://example.com', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
```
|
||||
|
||||
### `undici.install()` globals
|
||||
|
||||
If you want the installed `undici` package to provide the globals, call
|
||||
[`install()`](../api/GlobalInstallation.md):
|
||||
|
||||
```js
|
||||
import { install } from 'undici'
|
||||
|
||||
install()
|
||||
|
||||
const body = new FormData()
|
||||
body.set('name', 'some')
|
||||
body.set('someOtherProperty', '8000')
|
||||
|
||||
await fetch('https://example.com', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
```
|
||||
|
||||
`install()` replaces the global `fetch`, `Headers`, `Response`, `Request`, and
|
||||
`FormData` implementations with undici's versions, and also installs undici's
|
||||
`WebSocket`, `CloseEvent`, `ErrorEvent`, `MessageEvent`, and `EventSource`
|
||||
globals.
|
||||
|
||||
Avoid mixing implementations in the same request, for example:
|
||||
|
||||
```js
|
||||
import { fetch } from 'undici'
|
||||
|
||||
const body = new FormData()
|
||||
|
||||
await fetch('https://example.com', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
```
|
||||
|
||||
```js
|
||||
import { FormData } from 'undici'
|
||||
|
||||
const body = new FormData()
|
||||
|
||||
await fetch('https://example.com', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
```
|
||||
|
||||
Those combinations may behave differently across Node.js and undici versions.
|
||||
Using matching pairs keeps multipart handling predictable.
|
||||
|
||||
## When you do NOT need to install undici
|
||||
|
||||
If all of the following are true, you can rely on the built-in globals and skip
|
||||
adding undici to your dependencies:
|
||||
|
||||
- You only need the standard Fetch API (`fetch`, `Request`, `Response`,
|
||||
`Headers`, `FormData`).
|
||||
- You are running Node.js v18 or later.
|
||||
- You do not depend on features or bug fixes introduced in a version of undici
|
||||
newer than the one bundled with your Node.js release.
|
||||
- You want zero additional runtime dependencies.
|
||||
- You want cross-platform interoperability with browsers and other runtimes
|
||||
(Deno, Bun, Cloudflare Workers, etc.) using the same Fetch API surface.
|
||||
|
||||
This is common in applications that make straightforward HTTP requests or in
|
||||
libraries that target multiple JavaScript runtimes.
|
||||
|
||||
## When you SHOULD install undici
|
||||
|
||||
Install undici from npm when you need capabilities beyond the standard Fetch API:
|
||||
|
||||
### Advanced HTTP APIs
|
||||
|
||||
undici exposes `request`, `stream`, `pipeline`, and `connect` methods that
|
||||
provide lower-level control and significantly better performance than `fetch`:
|
||||
|
||||
```js
|
||||
import { request } from 'undici';
|
||||
|
||||
const { statusCode, headers, body } = await request('https://example.com');
|
||||
const data = await body.json();
|
||||
```
|
||||
|
||||
### Connection pooling and dispatchers
|
||||
|
||||
`Client`, `Pool`, `BalancedPool`, `Agent`, and their configuration options
|
||||
let you manage connection lifecycle, keep-alive behavior, pipelining depth,
|
||||
and concurrency limits:
|
||||
|
||||
```js
|
||||
import { Pool } from 'undici';
|
||||
|
||||
const pool = new Pool('https://example.com', { connections: 10 });
|
||||
const { body } = await pool.request({ path: '/', method: 'GET' });
|
||||
```
|
||||
|
||||
### Proxy support
|
||||
|
||||
`ProxyAgent` and `EnvHttpProxyAgent` handle HTTP(S) proxying. Note that
|
||||
Node.js v22.21.0+ and v24.0.0+ support environment-variable-based proxy
|
||||
configuration for the built-in `fetch` via the `--use-env-proxy` flag (or
|
||||
`NODE_USE_ENV_PROXY=1`). However, undici's `ProxyAgent` still provides
|
||||
programmatic control through the dispatcher API:
|
||||
|
||||
```js
|
||||
import { ProxyAgent, fetch } from 'undici';
|
||||
|
||||
const proxyAgent = new ProxyAgent('https://my-proxy.example.com:8080');
|
||||
const response = await fetch('https://example.com', { dispatcher: proxyAgent });
|
||||
```
|
||||
|
||||
### Testing and mocking
|
||||
|
||||
`MockAgent`, `MockClient`, and `MockPool` let you intercept and mock HTTP
|
||||
requests without patching globals or depending on external libraries:
|
||||
|
||||
```js
|
||||
import { MockAgent, setGlobalDispatcher, fetch } from 'undici';
|
||||
|
||||
const mockAgent = new MockAgent();
|
||||
setGlobalDispatcher(mockAgent);
|
||||
|
||||
const pool = mockAgent.get('https://example.com');
|
||||
pool.intercept({ path: '/api' }).reply(200, { message: 'mocked' });
|
||||
```
|
||||
|
||||
### Interceptors and middleware
|
||||
|
||||
Custom dispatchers and interceptors (retry, redirect, cache, DNS) give you
|
||||
fine-grained control over how requests are processed.
|
||||
|
||||
### Newer version than what Node.js bundles
|
||||
|
||||
The npm package often includes features, performance improvements, and bug fixes
|
||||
that have not yet landed in a Node.js release. If you need a specific fix or
|
||||
feature, you can install a newer version directly.
|
||||
|
||||
## Version compatibility
|
||||
|
||||
| Node.js version | Bundled undici version | Notes |
|
||||
|---|---|---|
|
||||
| v18.x | ~5.x | `fetch` is experimental (behind `--experimental-fetch` in early v18) |
|
||||
| v20.x | ~6.x | `fetch` is stable |
|
||||
| v22.x | ~6.x / ~7.x | `fetch` is stable |
|
||||
| v24.x | ~7.x | `fetch` is stable; env-proxy support via `--use-env-proxy` |
|
||||
|
||||
You can always check the exact bundled version at runtime with
|
||||
`process.versions.undici`.
|
||||
|
||||
Installing undici from npm does not replace the built-in globals. If you want
|
||||
your installed version to replace the global `fetch` and related classes, use
|
||||
[`install()`](../api/GlobalInstallation.md). Otherwise, import `fetch`
|
||||
directly from `'undici'`:
|
||||
|
||||
```js
|
||||
import { fetch } from 'undici' // uses your installed version, not the built-in
|
||||
```
|
||||
|
||||
## Further reading
|
||||
|
||||
- [API Reference: Fetch](../api/Fetch.md)
|
||||
- [API Reference: Client](../api/Client.md)
|
||||
- [API Reference: Pool](../api/Pool.md)
|
||||
- [API Reference: ProxyAgent](../api/ProxyAgent.md)
|
||||
- [API Reference: MockAgent](../api/MockAgent.md)
|
||||
- [API Reference: Global Installation](../api/GlobalInstallation.md)
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
# Writing tests
|
||||
|
||||
<!--type=misc-->
|
||||
|
||||
Undici is tuned for a production use case and its default will keep
|
||||
a socket open for a few seconds after an HTTP request is completed to
|
||||
remove the overhead of opening up a new socket. These settings that makes
|
||||
Undici shine in production are not a good fit for using Undici in automated
|
||||
tests, as it will result in longer execution times.
|
||||
|
||||
The following are good defaults that will keep the socket open for only 10ms:
|
||||
|
||||
```js
|
||||
import { request, setGlobalDispatcher, Agent } from 'undici'
|
||||
|
||||
const agent = new Agent({
|
||||
keepAliveTimeout: 10, // milliseconds
|
||||
keepAliveMaxTimeout: 10 // milliseconds
|
||||
})
|
||||
|
||||
setGlobalDispatcher(agent)
|
||||
```
|
||||
|
||||
## Guarding against unexpected disconnects
|
||||
|
||||
Undici's `Client` automatically reconnects after a socket error. This means
|
||||
a test can silently disconnect, reconnect, and still pass. Unfortunately,
|
||||
this could mask bugs like unexpected parser errors or protocol violations.
|
||||
To catch these silent reconnections, add a disconnect guard after creating
|
||||
a `Client`:
|
||||
|
||||
```js
|
||||
const { Client } = require('undici')
|
||||
const { test, after } = require('node:test')
|
||||
const { tspl } = require('@matteo.collina/tspl')
|
||||
|
||||
test('example with disconnect guard', async (t) => {
|
||||
t = tspl(t, { plan: 1 })
|
||||
|
||||
const client = new Client('http://localhost:3000')
|
||||
after(() => client.close())
|
||||
|
||||
client.on('disconnect', () => {
|
||||
if (!client.closed && !client.destroyed) {
|
||||
t.fail('unexpected disconnect')
|
||||
}
|
||||
})
|
||||
|
||||
// ... test logic ...
|
||||
})
|
||||
```
|
||||
|
||||
`client.close()` and `client.destroy()` both emit `'disconnect'` events, but
|
||||
those are expected. The guard only fails when a disconnect happens during the
|
||||
active test (i.e., `!client.closed && !client.destroyed` is true).
|
||||
|
||||
Skip the guard for tests where a disconnect is expected behavior, such as:
|
||||
|
||||
- Signal aborts (`signal.emit('abort')`, `ac.abort()`)
|
||||
- Server-side destruction (`res.destroy()`, `req.socket.destroy()`)
|
||||
- Client-side body destruction mid-stream (`data.body.destroy()`)
|
||||
- Timeout errors (`HeadersTimeoutError`, `BodyTimeoutError`)
|
||||
- Successful upgrades (the socket is detached from the `Client`)
|
||||
- Retry/reconnect tests where the disconnect triggers the retry
|
||||
- HTTP parser errors from malformed responses (`HTTPParserError`)
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
# Getting Started
|
||||
|
||||
<!--type=misc-->
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install undici
|
||||
```
|
||||
|
||||
## Fetch
|
||||
|
||||
The quickest way to get started is with `fetch`, which follows the
|
||||
[Fetch Standard](https://fetch.spec.whatwg.org/) and works the same way as
|
||||
the browser API:
|
||||
|
||||
```mjs
|
||||
import { fetch } from 'undici'
|
||||
|
||||
const res = await fetch('https://example.com')
|
||||
const data = await res.json()
|
||||
console.log(data)
|
||||
```
|
||||
|
||||
### Using the Request object
|
||||
|
||||
undici also exports a `Request` class that follows the Fetch Standard:
|
||||
|
||||
```mjs
|
||||
import { fetch, Request } from 'undici'
|
||||
|
||||
const req = new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ hello: 'world' })
|
||||
})
|
||||
const res = await fetch(req)
|
||||
console.log(res.status)
|
||||
```
|
||||
|
||||
### Streaming the response
|
||||
|
||||
`res.body` is a web `ReadableStream`. Use `pipeline` from
|
||||
`node:stream/promises` to stream it to a file:
|
||||
|
||||
```mjs
|
||||
import { fetch } from 'undici'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { createWriteStream } from 'node:fs'
|
||||
|
||||
const res = await fetch('https://example.com/large-file.zip')
|
||||
await pipeline(res.body, createWriteStream('./file.zip'))
|
||||
```
|
||||
|
||||
> Always consume or cancel the response body. In Node.js, garbage collection
|
||||
> is not aggressive enough to release connections promptly, so leaving a body
|
||||
> unread can cause connection leaks and stalled requests. See
|
||||
> [Specification Compliance - Garbage Collection](/#garbage-collection)
|
||||
> for details.
|
||||
|
||||
For more on `fetch`, see [API Reference: Fetch](api/Fetch.md).
|
||||
|
||||
## Dispatchers: Connection reuse and pooling
|
||||
|
||||
By default, `fetch`, `request`, `stream`, and `pipeline` create a new connection
|
||||
for each call. For applications that make many requests to the same origin,
|
||||
this is wasteful. undici provides **dispatchers** that manage connections
|
||||
internally.
|
||||
|
||||
### `Agent` — for requests to multiple origins
|
||||
|
||||
`Agent` is the most general-purpose dispatcher. It pools connections per-origin
|
||||
and is the recommended default for most applications. Use it with
|
||||
`setGlobalDispatcher` to affect all undici calls globally:
|
||||
|
||||
```js
|
||||
import { Agent, setGlobalDispatcher, fetch } from 'undici'
|
||||
|
||||
const agent = new Agent({
|
||||
keepAliveTimeout: 30_000,
|
||||
keepAliveMaxTimeout: 600_000
|
||||
})
|
||||
setGlobalDispatcher(agent)
|
||||
|
||||
// All subsequent fetch/request/stream/pipeline calls reuse connections
|
||||
const res = await fetch('https://api.example.com/data')
|
||||
```
|
||||
|
||||
You can also pass a dispatcher per-request:
|
||||
|
||||
```js
|
||||
await fetch('https://api.example.com/data', { dispatcher: agent })
|
||||
```
|
||||
|
||||
### `Pool` — for requests to a single origin
|
||||
|
||||
`Pool` manages a fixed set of connections to one origin. It gives you explicit
|
||||
control over concurrency:
|
||||
|
||||
```js
|
||||
import { Pool, request } from 'undici'
|
||||
|
||||
const pool = new Pool('https://api.example.com', { connections: 10 })
|
||||
|
||||
const { body } = await request('https://api.example.com/data', {
|
||||
dispatcher: pool
|
||||
})
|
||||
const data = await body.json()
|
||||
|
||||
pool.close()
|
||||
```
|
||||
|
||||
### `Client` — for a single connection
|
||||
|
||||
`Client` maps to a single TCP connection. It supports pipelining (sending
|
||||
multiple requests before responses arrive), which should only be enabled for
|
||||
trusted remote servers:
|
||||
|
||||
```js
|
||||
import { Client } from 'undici'
|
||||
|
||||
const client = new Client('https://api.example.com', {
|
||||
pipelining: 5
|
||||
})
|
||||
|
||||
const { body } = await client.request({ path: '/', method: 'GET' })
|
||||
await body.dump()
|
||||
|
||||
client.close()
|
||||
```
|
||||
|
||||
For more on dispatcher options and lifecycle, see:
|
||||
- [API Reference: Agent](api/Agent.md)
|
||||
- [API Reference: Pool](api/Pool.md)
|
||||
- [API Reference: Client](api/Client.md)
|
||||
|
||||
## Timeouts
|
||||
|
||||
undici applies timeouts at two levels:
|
||||
|
||||
- **`headersTimeout`** — time to wait for response headers (default: 300s).
|
||||
- **`bodyTimeout`** — time between consecutive body chunks (default: 300s).
|
||||
|
||||
Set these on the dispatcher or per-request:
|
||||
|
||||
```js
|
||||
import { Agent, setGlobalDispatcher } from 'undici'
|
||||
|
||||
const agent = new Agent({
|
||||
headersTimeout: 5_000,
|
||||
bodyTimeout: 30_000
|
||||
})
|
||||
|
||||
setGlobalDispatcher(agent)
|
||||
```
|
||||
|
||||
Timeout errors are thrown as `HeadersTimeoutError` and `BodyTimeoutError`.
|
||||
See [API Reference: Errors](api/Errors.md) for the full list.
|
||||
|
||||
## Error handling
|
||||
|
||||
undici exposes structured errors via `error.code`:
|
||||
|
||||
```js
|
||||
import { request, errors } from 'undici'
|
||||
|
||||
try {
|
||||
const { body } = await request('https://example.com')
|
||||
await body.json()
|
||||
} catch (err) {
|
||||
switch (err.code) {
|
||||
case 'UND_ERR_CONNECT_TIMEOUT':
|
||||
console.error('Connection timed out')
|
||||
break
|
||||
case 'UND_ERR_HEADERS_TIMEOUT':
|
||||
console.error('Headers timed out')
|
||||
break
|
||||
case 'UND_ERR_BODY_TIMEOUT':
|
||||
console.error('Body timed out')
|
||||
break
|
||||
case 'UND_ERR_ABORTED':
|
||||
console.error('Request was aborted')
|
||||
break
|
||||
default:
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Aborting requests
|
||||
|
||||
```js
|
||||
import { request } from 'undici'
|
||||
|
||||
const ac = new AbortController()
|
||||
|
||||
setTimeout(() => ac.abort(), 1000)
|
||||
|
||||
try {
|
||||
const { body } = await request('https://example.com', {
|
||||
signal: ac.signal
|
||||
})
|
||||
await body.dump()
|
||||
} catch (err) {
|
||||
console.error(err.code) // UND_ERR_ABORTED
|
||||
}
|
||||
```
|
||||
|
||||
## Common patterns
|
||||
|
||||
### Proxies
|
||||
|
||||
Use `ProxyAgent` for HTTP(S) proxies, or `EnvHttpProxyAgent` to pick up
|
||||
proxy settings from environment variables:
|
||||
|
||||
```js
|
||||
import { ProxyAgent, setGlobalDispatcher } from 'undici'
|
||||
|
||||
const proxy = new ProxyAgent('http://proxy.internal:8080')
|
||||
setGlobalDispatcher(proxy)
|
||||
```
|
||||
|
||||
See [Best Practices: Proxy](best-practices/proxy.md) and
|
||||
[API Reference: ProxyAgent](api/ProxyAgent.md).
|
||||
|
||||
### Mocking in tests
|
||||
|
||||
```js
|
||||
import { MockAgent, setGlobalDispatcher, request } from 'undici'
|
||||
|
||||
const mockAgent = new MockAgent()
|
||||
setGlobalDispatcher(mockAgent)
|
||||
|
||||
const mockPool = mockAgent.get('https://api.example.com')
|
||||
mockPool.intercept({ path: '/users' }).reply(200, [{ id: 1 }])
|
||||
|
||||
const { body } = await request('https://api.example.com/users')
|
||||
console.log(await body.json())
|
||||
```
|
||||
|
||||
See [Best Practices: Mocking Request](best-practices/mocking-request.md)
|
||||
and [API Reference: MockAgent](api/MockAgent.md).
|
||||
|
||||
### Testing with undici
|
||||
|
||||
For test suites, set short keep-alive timeouts to avoid slow teardowns:
|
||||
|
||||
```js
|
||||
import { Agent, setGlobalDispatcher } from 'undici'
|
||||
|
||||
const agent = new Agent({
|
||||
keepAliveTimeout: 10,
|
||||
keepAliveMaxTimeout: 10
|
||||
})
|
||||
setGlobalDispatcher(agent)
|
||||
```
|
||||
|
||||
See [Best Practices: Writing Tests](best-practices/writing-tests.md).
|
||||
|
||||
### Customizing the global fetch
|
||||
|
||||
You can override Node.js's built-in globals with `install()`:
|
||||
|
||||
```js
|
||||
import { install } from 'undici'
|
||||
|
||||
install()
|
||||
|
||||
// Global fetch, Headers, Response, Request, and FormData
|
||||
// now come from undici, not the Node.js bundle
|
||||
const res = await fetch('https://example.com')
|
||||
```
|
||||
|
||||
See [API Reference: Global Installation](api/GlobalInstallation.md).
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Undici vs. Built-in Fetch](best-practices/undici-vs-builtin-fetch.md) —
|
||||
when to install undici vs using Node.js built-in fetch
|
||||
- [API Reference](api/Dispatcher.md) — full dispatcher API documentation
|
||||
- [Examples](/examples/) — runnable code examples
|
||||
+793
@@ -0,0 +1,793 @@
|
||||
# undici
|
||||
|
||||
<!--type=misc-->
|
||||
|
||||
[](https://github.com/nodejs/undici/actions/workflows/nodejs.yml) [](https://github.com/neostandard/neostandard) [](https://badge.fury.io/js/undici) [](https://codecov.io/gh/nodejs/undici)
|
||||
|
||||
An HTTP/1.1 client, written from scratch for Node.js.
|
||||
|
||||
> Undici means eleven in Italian. 1.1 -> 11 -> Eleven -> Undici.
|
||||
It is also a Stranger Things reference.
|
||||
|
||||
## How to get involved
|
||||
|
||||
Have a question about using Undici? Open a [Q&A Discussion](https://github.com/nodejs/undici/discussions/new) or join our official OpenJS [Slack](https://openjs-foundation.slack.com/archives/C01QF9Q31QD) channel.
|
||||
|
||||
Looking to contribute? Start by reading the [contributing guide](./CONTRIBUTING.md)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm i undici
|
||||
```
|
||||
|
||||
## Benchmarks
|
||||
|
||||
The benchmark is a simple getting data [example](https://github.com/nodejs/undici/blob/main/benchmarks/benchmark.js) using
|
||||
50 TCP connections with a pipelining depth of 10 running on Node 24.14.1.
|
||||
|
||||
### HTTP/1.1
|
||||
|
||||
```text
|
||||
┌────────────────────────┬─────────┬────────────────────┬────────────┬─────────────────────────┐
|
||||
│ Tests │ Samples │ Result │ Tolerance │ Difference with slowest │
|
||||
├────────────────────────┼─────────┼────────────────────┼────────────┼─────────────────────────┤
|
||||
│ 'node-fetch' │ 50 │ '4711.86 req/sec' │ '± 2.92 %' │ '-' │
|
||||
│ 'undici - fetch' │ 75 │ '5438.50 req/sec' │ '± 2.97 %' │ '+ 15.42 %' │
|
||||
│ 'axios' │ 45 │ '5448.08 req/sec' │ '± 2.98 %' │ '+ 15.62 %' │
|
||||
│ 'request' │ 65 │ '5809.63 req/sec' │ '± 2.90 %' │ '+ 23.30 %' │
|
||||
│ 'http - no keepalive' │ 35 │ '5910.77 req/sec' │ '± 2.87 %' │ '+ 25.44 %' │
|
||||
│ 'got' │ 50 │ '6047.80 req/sec' │ '± 2.91 %' │ '+ 28.35 %' │
|
||||
│ 'superagent' │ 60 │ '7534.53 req/sec' │ '± 2.97 %' │ '+ 59.91 %' │
|
||||
│ 'http - keepalive' │ 75 │ '9343.41 req/sec' │ '± 2.90 %' │ '+ 98.30 %' │
|
||||
│ 'undici - pipeline' │ 65 │ '13470.70 req/sec' │ '± 2.93 %' │ '+ 185.89 %' │
|
||||
│ 'undici - request' │ 80 │ '16850.87 req/sec' │ '± 2.93 %' │ '+ 257.63 %' │
|
||||
│ 'undici - stream' │ 101 │ '18488.56 req/sec' │ '± 3.81 %' │ '+ 292.38 %' │
|
||||
│ 'undici - dispatch' │ 101 │ '20786.44 req/sec' │ '± 3.08 %' │ '+ 341.15 %' │
|
||||
└────────────────────────┴─────────┴────────────────────┴────────────┴─────────────────────────┘
|
||||
```
|
||||
|
||||
### HTTP/1.1 over HTTPS
|
||||
|
||||
Using [benchmark-https.js](https://github.com/nodejs/undici/blob/main/benchmarks/benchmark-https.js) against an h1-over-TLS server (50 connections, pipelining depth 10, Node 24.14.1).
|
||||
|
||||
```text
|
||||
┌────────────────────────┬─────────┬───────────────────┬────────────┬─────────────────────────┐
|
||||
│ Tests │ Samples │ Result │ Tolerance │ Difference with slowest │
|
||||
├────────────────────────┼─────────┼───────────────────┼────────────┼─────────────────────────┤
|
||||
│ 'https - no keepalive'│ 10 │ '1358.40 req/sec' │ '± 1.99 %' │ '-' │
|
||||
│ 'undici - fetch' │ 30 │ '3721.76 req/sec' │ '± 2.97 %' │ '+ 173.98 %' │
|
||||
│ 'https - keepalive' │ 35 │ '5633.91 req/sec' │ '± 2.84 %' │ '+ 314.75 %' │
|
||||
│ 'undici - pipeline' │ 15 │ '6254.05 req/sec' │ '± 2.80 %' │ '+ 360.40 %' │
|
||||
│ 'undici - request' │ 25 │ '6669.80 req/sec' │ '± 2.73 %' │ '+ 391.01 %' │
|
||||
│ 'undici - stream' │ 25 │ '7019.04 req/sec' │ '± 2.77 %' │ '+ 416.71 %' │
|
||||
│ 'undici - dispatch' │ 20 │ '7361.85 req/sec' │ '± 2.90 %' │ '+ 441.95 %' │
|
||||
└────────────────────────┴─────────┴───────────────────┴────────────┴─────────────────────────┘
|
||||
```
|
||||
|
||||
### HTTP/2
|
||||
|
||||
Using [benchmark-http2.js](https://github.com/nodejs/undici/blob/main/benchmarks/benchmark-http2.js) against an h2 server (50 connections, pipelining depth 10, Node 24.14.1).
|
||||
|
||||
```text
|
||||
┌────────────────────────┬─────────┬───────────────────┬────────────┬─────────────────────────┐
|
||||
│ Tests │ Samples │ Result │ Tolerance │ Difference with slowest │
|
||||
├────────────────────────┼─────────┼───────────────────┼────────────┼─────────────────────────┤
|
||||
│ 'undici - fetch' │ 45 │ '3499.03 req/sec' │ '± 2.93 %' │ '-' │
|
||||
│ 'native - http2' │ 25 │ '4904.58 req/sec' │ '± 2.81 %' │ '+ 40.17 %' │
|
||||
│ 'undici - pipeline' │ 60 │ '5836.82 req/sec' │ '± 2.99 %' │ '+ 66.81 %' │
|
||||
│ 'undici - request' │ 65 │ '6831.25 req/sec' │ '± 2.83 %' │ '+ 95.23 %' │
|
||||
│ 'undici - stream' │ 55 │ '6874.30 req/sec' │ '± 2.91 %' │ '+ 96.46 %' │
|
||||
│ 'undici - dispatch' │ 55 │ '7791.23 req/sec' │ '± 2.96 %' │ '+ 122.67 %' │
|
||||
└────────────────────────┴─────────┴───────────────────┴────────────┴─────────────────────────┘
|
||||
```
|
||||
|
||||
## Undici vs. Fetch
|
||||
|
||||
### Overview
|
||||
|
||||
Node.js includes a built-in `fetch()` implementation powered by undici starting from Node.js v18. However, there are important differences between using the built-in fetch and installing undici as a separate module.
|
||||
|
||||
### Built-in Fetch (Node.js v18+)
|
||||
|
||||
Node.js's built-in fetch is powered by a bundled version of undici:
|
||||
|
||||
```js
|
||||
// Available globally in Node.js v18+
|
||||
const response = await fetch('https://api.example.com/data');
|
||||
const data = await response.json();
|
||||
|
||||
// Check the bundled undici version
|
||||
console.log(process.versions.undici); // e.g., "5.28.4"
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- No additional dependencies required
|
||||
- Works across different JavaScript runtimes
|
||||
- Automatic compression handling (gzip, deflate, br)
|
||||
- Built-in caching support (in development)
|
||||
|
||||
**Cons:**
|
||||
- Limited to the undici version bundled with your Node.js version
|
||||
- Less control over connection pooling and advanced features
|
||||
- Error handling follows Web API standards (errors wrapped in `TypeError`)
|
||||
- Performance overhead due to Web Streams implementation
|
||||
|
||||
### Undici Module
|
||||
|
||||
Installing undici as a separate module gives you access to the latest features and APIs:
|
||||
|
||||
```bash
|
||||
npm install undici
|
||||
```
|
||||
|
||||
```js
|
||||
import { request, fetch, Agent, setGlobalDispatcher } from 'undici';
|
||||
|
||||
// Use undici.request for maximum performance
|
||||
const { statusCode, headers, body } = await request('https://api.example.com/data');
|
||||
const data = await body.json();
|
||||
|
||||
// Or use undici.fetch with custom configuration
|
||||
const agent = new Agent({ keepAliveTimeout: 10000 });
|
||||
setGlobalDispatcher(agent);
|
||||
const response = await fetch('https://api.example.com/data');
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Latest undici features and bug fixes
|
||||
- Access to advanced APIs (`request`, `stream`, `pipeline`)
|
||||
- Fine-grained control over connection pooling
|
||||
- Better error handling with clearer error messages
|
||||
- Superior performance, especially with `undici.request`
|
||||
- HTTP/1.1 pipelining support
|
||||
- Custom interceptors and middleware
|
||||
- Advanced features like `ProxyAgent`, `Socks5Agent`, `MockAgent`
|
||||
|
||||
**Cons:**
|
||||
- Additional dependency to manage
|
||||
- Larger bundle size
|
||||
|
||||
### When to Use Each
|
||||
|
||||
#### Use Built-in Fetch When:
|
||||
- You want zero dependencies
|
||||
- Building isomorphic code that runs in browsers and Node.js
|
||||
- Publishing to npm and want to maximize compatibility with JS runtimes
|
||||
- Simple HTTP requests without advanced configuration
|
||||
- You're publishing to npm and you want to maximize compatiblity
|
||||
- You don't depend on features from a specific version of undici
|
||||
|
||||
#### Use Undici Module When:
|
||||
- You need the latest undici features and performance improvements
|
||||
- You require advanced connection pooling configuration
|
||||
- You need APIs not available in the built-in fetch (`ProxyAgent`, `Socks5Agent`, `MockAgent`, etc.)
|
||||
- Performance is critical (use `undici.request` for maximum speed)
|
||||
- You want better error handling and debugging capabilities
|
||||
- You need HTTP/1.1 pipelining or advanced interceptors
|
||||
- You prefer decoupled protocol and API interfaces
|
||||
|
||||
### Performance Comparison
|
||||
|
||||
Based on benchmarks, here's the typical performance hierarchy:
|
||||
|
||||
1. **`undici.request()`** - Fastest, most efficient
|
||||
2. **`undici.fetch()`** - Good performance, standard compliance
|
||||
3. **Node.js `http`/`https`** - Baseline performance
|
||||
|
||||
### Migration Guide
|
||||
|
||||
If you're currently using built-in fetch and want to migrate to undici:
|
||||
|
||||
```js
|
||||
// Before: Built-in fetch
|
||||
const response = await fetch('https://api.example.com/data');
|
||||
|
||||
// After: Undici fetch (drop-in replacement)
|
||||
import { fetch } from 'undici';
|
||||
const response = await fetch('https://api.example.com/data');
|
||||
|
||||
// Or: Undici request (better performance)
|
||||
import { request } from 'undici';
|
||||
const { statusCode, body } = await request('https://api.example.com/data');
|
||||
const data = await body.json();
|
||||
```
|
||||
|
||||
### Keep `fetch` and `FormData` together
|
||||
|
||||
When you send a `FormData` body, keep `fetch` and `FormData` from the same
|
||||
implementation.
|
||||
|
||||
Use one of these patterns:
|
||||
|
||||
```js
|
||||
// Built-in globals
|
||||
const body = new FormData()
|
||||
body.set('name', 'some')
|
||||
await fetch('https://example.com', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
```
|
||||
|
||||
```js
|
||||
// undici module imports
|
||||
import { fetch, FormData } from 'undici'
|
||||
|
||||
const body = new FormData()
|
||||
body.set('name', 'some')
|
||||
await fetch('https://example.com', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
```
|
||||
|
||||
If you want the installed `undici` package to provide the globals, call
|
||||
`install()` first:
|
||||
|
||||
```js
|
||||
import { install } from 'undici'
|
||||
|
||||
install()
|
||||
|
||||
const body = new FormData()
|
||||
body.set('name', 'some')
|
||||
await fetch('https://example.com', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
```
|
||||
|
||||
`install()` replaces the global `fetch`, `Headers`, `Response`, `Request`, and
|
||||
`FormData` implementations with undici's versions, so they all match. It also
|
||||
installs undici's `WebSocket`, `CloseEvent`, `ErrorEvent`, `MessageEvent`, and
|
||||
`EventSource` globals.
|
||||
|
||||
Avoid mixing a global `FormData` with `undici.fetch()`, or `undici.FormData`
|
||||
with the built-in global `fetch()`.
|
||||
|
||||
### Version Compatibility
|
||||
|
||||
You can check which version of undici is bundled with your Node.js version:
|
||||
|
||||
```js
|
||||
console.log(process.versions.undici);
|
||||
```
|
||||
|
||||
Installing undici as a module allows you to use a newer version than what's bundled with Node.js, giving you access to the latest features and performance improvements.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Request
|
||||
|
||||
```js
|
||||
import { request } from 'undici'
|
||||
|
||||
const {
|
||||
statusCode,
|
||||
headers,
|
||||
trailers,
|
||||
body
|
||||
} = await request('http://localhost:3000/foo')
|
||||
|
||||
console.log('response received', statusCode)
|
||||
console.log('headers', headers)
|
||||
|
||||
for await (const data of body) { console.log('data', data) }
|
||||
|
||||
console.log('trailers', trailers)
|
||||
```
|
||||
|
||||
### Using Cache Interceptor
|
||||
|
||||
Undici provides a powerful HTTP caching interceptor that follows HTTP caching best practices. Here's how to use it:
|
||||
|
||||
```js
|
||||
import { fetch, Agent, interceptors, cacheStores } from 'undici';
|
||||
|
||||
// Create a client with cache interceptor
|
||||
const client = new Agent().compose(interceptors.cache({
|
||||
// Optional: Configure cache store (defaults to MemoryCacheStore)
|
||||
store: new cacheStores.MemoryCacheStore({
|
||||
maxSize: 100 * 1024 * 1024, // 100MB
|
||||
maxCount: 1000,
|
||||
maxEntrySize: 5 * 1024 * 1024 // 5MB
|
||||
}),
|
||||
|
||||
// Optional: Specify which HTTP methods to cache (default: ['GET', 'HEAD'])
|
||||
methods: ['GET', 'HEAD']
|
||||
}));
|
||||
|
||||
// Set the global dispatcher to use our caching client
|
||||
setGlobalDispatcher(client);
|
||||
|
||||
// Now all fetch requests will use the cache
|
||||
async function getData() {
|
||||
const response = await fetch('https://api.example.com/data');
|
||||
// The server should set appropriate Cache-Control headers in the response
|
||||
// which the cache will respect based on the cache policy
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// First request - fetches from origin
|
||||
const data1 = await getData();
|
||||
|
||||
// Second request - served from cache if within max-age
|
||||
const data2 = await getData();
|
||||
```
|
||||
|
||||
#### Key Features:
|
||||
- **Automatic Caching**: Respects `Cache-Control` and `Expires` headers
|
||||
- **Validation**: Supports `ETag` and `Last-Modified` validation
|
||||
- **Storage Options**: In-memory or persistent SQLite storage
|
||||
- **Flexible**: Configure cache size, TTL, and more
|
||||
|
||||
## Global Installation
|
||||
|
||||
Undici provides an `install()` function to add fetch-related and other web API classes to `globalThis`, making them available globally:
|
||||
|
||||
```js
|
||||
import { install } from 'undici'
|
||||
|
||||
// Install undici's global web APIs
|
||||
install()
|
||||
|
||||
// Now you can use fetch classes globally without importing
|
||||
const response = await fetch('https://api.example.com/data')
|
||||
const data = await response.json()
|
||||
|
||||
// All classes are available globally:
|
||||
const headers = new Headers([['content-type', 'application/json']])
|
||||
const request = new Request('https://example.com')
|
||||
const formData = new FormData()
|
||||
const ws = new WebSocket('wss://example.com')
|
||||
const eventSource = new EventSource('https://example.com/events')
|
||||
```
|
||||
|
||||
The `install()` function adds the following classes to `globalThis`:
|
||||
|
||||
- `fetch` - The fetch function
|
||||
- `Headers` - HTTP headers management
|
||||
- `Response` - HTTP response representation
|
||||
- `Request` - HTTP request representation
|
||||
- `FormData` - Form data handling
|
||||
- `WebSocket` - WebSocket client
|
||||
- `CloseEvent`, `ErrorEvent`, `MessageEvent` - WebSocket events
|
||||
- `EventSource` - Server-sent events client
|
||||
|
||||
When you call `install()`, these globals come from the same undici
|
||||
implementation. For example, global `fetch` and global `FormData` will both be
|
||||
undici's versions, and `WebSocket` and `EventSource` will also come from
|
||||
undici, which is the recommended setup if you want to use undici through
|
||||
globals.
|
||||
|
||||
This is useful for:
|
||||
- Polyfilling environments that don't have fetch
|
||||
- Ensuring consistent fetch behavior across different Node.js versions
|
||||
- Making undici's implementations available globally for libraries that expect them
|
||||
|
||||
## Body Mixins
|
||||
|
||||
The `body` mixins are the most common way to format the request/response body. Mixins include:
|
||||
|
||||
- [`.arrayBuffer()`](https://fetch.spec.whatwg.org/#dom-body-arraybuffer)
|
||||
- [`.blob()`](https://fetch.spec.whatwg.org/#dom-body-blob)
|
||||
- [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes)
|
||||
- [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json)
|
||||
- [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text)
|
||||
|
||||
> [!NOTE]
|
||||
> The body returned from `undici.request` does not implement `.formData()`.
|
||||
|
||||
> [!WARNING]
|
||||
> The body mixins `.arrayBuffer()`, `.blob()`, `.bytes()`, `.json()`, `.text()`,
|
||||
> and `.formData()` buffer the entire body in memory before returning. Where
|
||||
> applicable, they also decode or parse the payload and retain that
|
||||
> representation in memory. Calling these methods therefore means trusting that
|
||||
> the response body is small enough to fit in the available memory. Do not use
|
||||
> them for responses from untrusted or user-controlled sources. Instead, consume
|
||||
> the response body as a stream and enforce an application-specific size limit:
|
||||
> use `response.body` for fetch responses or the `body` returned by
|
||||
> `undici.request()`. For streaming decoded text, use `body.textStream()` on a
|
||||
> fetch `Request` or `Response`.
|
||||
|
||||
Example usage:
|
||||
|
||||
```js
|
||||
import { request } from 'undici'
|
||||
|
||||
const {
|
||||
statusCode,
|
||||
headers,
|
||||
trailers,
|
||||
body
|
||||
} = await request('http://localhost:3000/foo')
|
||||
|
||||
console.log('response received', statusCode)
|
||||
console.log('headers', headers)
|
||||
console.log('data', await body.json())
|
||||
console.log('trailers', trailers)
|
||||
```
|
||||
|
||||
_Note: Once a mixin has been called then the body cannot be reused, thus calling additional mixins on `.body`, e.g. `.body.json(); .body.text()` will result in an error `TypeError: unusable` being thrown and returned through the `Promise` rejection._
|
||||
|
||||
Should you need to access the `body` in plain-text after using a mixin, the best practice is to use the `.text()` mixin first and then manually parse the text to the desired format.
|
||||
|
||||
For more information about their behavior, please reference the body mixin from the [Fetch Standard](https://fetch.spec.whatwg.org/#body-mixin).
|
||||
|
||||
## Common API Methods
|
||||
|
||||
This section documents our most commonly used API methods. Additional APIs are documented in their own files within the [docs](./docs/) folder and are accessible via the navigation list on the left side of the docs site.
|
||||
|
||||
For the top-level APIs below, the `url` argument supplies the request origin and
|
||||
path. Do not pass `origin` or `path` in the second `options` argument. The linked
|
||||
`Dispatcher` option types include those fields because dispatcher methods are
|
||||
lower-level APIs that do not receive a separate `url` argument.
|
||||
|
||||
### `undici.request([url, options])`
|
||||
|
||||
* `url` {string|URL|UrlObject}
|
||||
* `options` {RequestOptions} See [`RequestOptions`](./docs/docs/api/Dispatcher.md#parameter-requestoptions).
|
||||
* `dispatcher` {Dispatcher} **Default:** [getGlobalDispatcher](#undicigetglobaldispatcher).
|
||||
* `method` {string} **Default:** `PUT` if `options.body`, otherwise `GET`.
|
||||
* Returns: {Promise} A promise with the result of the `Dispatcher.request` method.
|
||||
|
||||
Calls `options.dispatcher.request(options)`.
|
||||
|
||||
See [Dispatcher.request](./docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback) for more details, and [request examples](./docs/examples/README.md) for examples.
|
||||
|
||||
### `undici.stream([url, options, ]factory)`
|
||||
|
||||
* `url` {string|URL|UrlObject}
|
||||
* `options` {StreamOptions} See [`StreamOptions`](./docs/docs/api/Dispatcher.md#parameter-streamoptions).
|
||||
* `dispatcher` {Dispatcher} **Default:** [getGlobalDispatcher](#undicigetglobaldispatcher).
|
||||
* `method` {string} **Default:** `PUT` if `options.body`, otherwise `GET`.
|
||||
* `factory` {Function} `Dispatcher.stream.factory`.
|
||||
* Returns: {Promise} A promise with the result of the `Dispatcher.stream` method.
|
||||
|
||||
Calls `options.dispatcher.stream(options, factory)`.
|
||||
|
||||
See [Dispatcher.stream](./docs/docs/api/Dispatcher.md#dispatcherstreamoptions-factory-callback) for more details.
|
||||
|
||||
### `undici.pipeline([url, options, ]handler)`
|
||||
|
||||
* `url` {string|URL|UrlObject}
|
||||
* `options` {PipelineOptions} See [`PipelineOptions`](./docs/docs/api/Dispatcher.md#parameter-pipelineoptions).
|
||||
* `dispatcher` {Dispatcher} **Default:** [getGlobalDispatcher](#undicigetglobaldispatcher).
|
||||
* `method` {string} **Default:** `PUT` if `options.body`, otherwise `GET`.
|
||||
* `handler` {Function} `Dispatcher.pipeline.handler`.
|
||||
* Returns: {Duplex} A `stream.Duplex`.
|
||||
|
||||
Calls `options.dispatch.pipeline(options, handler)`.
|
||||
|
||||
See [Dispatcher.pipeline](./docs/docs/api/Dispatcher.md#dispatcherpipelineoptions-handler) for more details.
|
||||
|
||||
### `undici.connect([url, options])`
|
||||
|
||||
Starts two-way communications with the requested resource using [HTTP CONNECT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/CONNECT).
|
||||
|
||||
* `url` {string|URL|UrlObject}
|
||||
* `options` {ConnectOptions} See [`ConnectOptions`](./docs/docs/api/Dispatcher.md#parameter-connectoptions).
|
||||
* `dispatcher` {Dispatcher} **Default:** [getGlobalDispatcher](#undicigetglobaldispatcher).
|
||||
* `callback` {Function} (optional) — `(err, data) => void`.
|
||||
* Returns: {Promise} A promise with the result of the `Dispatcher.connect` method.
|
||||
|
||||
Calls `options.dispatch.connect(options)`.
|
||||
|
||||
See [Dispatcher.connect](./docs/docs/api/Dispatcher.md#dispatcherconnectoptions-callback) for more details.
|
||||
|
||||
### `undici.fetch(input[, init])`
|
||||
|
||||
Implements [fetch](https://fetch.spec.whatwg.org/#fetch-method).
|
||||
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch
|
||||
* https://fetch.spec.whatwg.org/#fetch-method
|
||||
|
||||
Basic usage example:
|
||||
|
||||
```js
|
||||
import { fetch } from 'undici'
|
||||
|
||||
|
||||
const res = await fetch('https://example.com')
|
||||
const json = await res.json()
|
||||
console.log(json)
|
||||
```
|
||||
|
||||
You can pass an optional dispatcher to `fetch` as:
|
||||
|
||||
```js
|
||||
import { fetch, Agent } from 'undici'
|
||||
|
||||
const res = await fetch('https://example.com', {
|
||||
// Mocks are also supported
|
||||
dispatcher: new Agent({
|
||||
keepAliveTimeout: 10,
|
||||
keepAliveMaxTimeout: 10
|
||||
})
|
||||
})
|
||||
const json = await res.json()
|
||||
console.log(json)
|
||||
```
|
||||
|
||||
#### `request.body`
|
||||
|
||||
A body can be of the following types:
|
||||
|
||||
- ArrayBuffer
|
||||
- ArrayBufferView
|
||||
- AsyncIterables
|
||||
- Blob
|
||||
- Iterables
|
||||
- String
|
||||
- URLSearchParams
|
||||
- FormData
|
||||
|
||||
In this implementation of fetch, ```request.body``` now accepts ```Async Iterables```. It is not present in the [Fetch Standard](https://fetch.spec.whatwg.org).
|
||||
|
||||
```js
|
||||
import { fetch } from 'undici'
|
||||
|
||||
const data = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield 'hello'
|
||||
yield 'world'
|
||||
},
|
||||
}
|
||||
|
||||
await fetch('https://example.com', { body: data, method: 'POST', duplex: 'half' })
|
||||
```
|
||||
|
||||
[FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData) besides text data and buffers can also utilize streams via [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) objects:
|
||||
|
||||
```js
|
||||
import { openAsBlob } from 'node:fs'
|
||||
|
||||
const file = await openAsBlob('./big.csv')
|
||||
const body = new FormData()
|
||||
body.set('file', file, 'big.csv')
|
||||
|
||||
await fetch('http://example.com', { method: 'POST', body })
|
||||
```
|
||||
|
||||
#### `request.duplex`
|
||||
|
||||
- `'half'`
|
||||
|
||||
In this implementation of fetch, `request.duplex` must be set if `request.body` is `ReadableStream` or `Async Iterables`, however, even though the value must be set to `'half'`, it is actually a _full_ duplex. For more detail refer to the [Fetch Standard](https://fetch.spec.whatwg.org/#dom-requestinit-duplex).
|
||||
|
||||
#### `response.body`
|
||||
|
||||
Nodejs has two kinds of streams: [web streams](https://nodejs.org/api/webstreams.html), which follow the API of the WHATWG web standard found in browsers, and an older Node-specific [streams API](https://nodejs.org/api/stream.html). `response.body` returns a readable web stream. If you would prefer to work with a Node stream you can convert a web stream using `.fromWeb()`.
|
||||
|
||||
```js
|
||||
import { fetch } from 'undici'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
const response = await fetch('https://example.com')
|
||||
const readableWebStream = response.body
|
||||
const readableNodeStream = Readable.fromWeb(readableWebStream)
|
||||
```
|
||||
|
||||
## Specification Compliance
|
||||
|
||||
This section documents parts of the [HTTP/1.1](https://www.rfc-editor.org/rfc/rfc9110.html) and [Fetch Standard](https://fetch.spec.whatwg.org) that Undici does
|
||||
not support or does not fully implement.
|
||||
|
||||
#### CORS
|
||||
|
||||
Unlike browsers, Undici does not implement CORS (Cross-Origin Resource Sharing) checks by default. This means:
|
||||
|
||||
- No preflight requests are automatically sent for cross-origin requests
|
||||
- No validation of `Access-Control-Allow-Origin` headers is performed
|
||||
- Requests to any origin are allowed regardless of the source
|
||||
|
||||
This behavior is intentional for server-side environments where CORS restrictions are typically unnecessary. If your application requires CORS-like protections, you will need to implement these checks manually.
|
||||
|
||||
#### Garbage Collection
|
||||
|
||||
* https://fetch.spec.whatwg.org/#garbage-collection
|
||||
|
||||
The [Fetch Standard](https://fetch.spec.whatwg.org) allows users to skip consuming the response body by relying on
|
||||
[garbage collection](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management#garbage_collection) to release connection resources.
|
||||
|
||||
Garbage collection in Node is less aggressive and deterministic
|
||||
(due to the lack of clear idle periods that browsers have through the rendering refresh rate)
|
||||
which means that leaving the release of connection resources to the garbage collector can lead
|
||||
to excessive connection usage, reduced performance (due to less connection re-use), and even
|
||||
stalls or deadlocks when running out of connections.
|
||||
Therefore, __it is important to always either consume or cancel the response body anyway__.
|
||||
|
||||
```js
|
||||
// Do
|
||||
const { body, headers } = await fetch(url);
|
||||
for await (const chunk of body) {
|
||||
// force consumption of body
|
||||
}
|
||||
|
||||
// Do not
|
||||
const { headers } = await fetch(url);
|
||||
```
|
||||
|
||||
However, if you want to get only headers, it might be better to use `HEAD` request method. Usage of this method will obviate the need for consumption or cancelling of the response body. See [MDN - HTTP - HTTP request methods - HEAD](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD) for more details.
|
||||
|
||||
```js
|
||||
const headers = await fetch(url, { method: 'HEAD' })
|
||||
.then(res => res.headers)
|
||||
```
|
||||
|
||||
Note that consuming the response body is _mandatory_ for `request`:
|
||||
|
||||
```js
|
||||
// Do
|
||||
const { body, headers } = await request(url);
|
||||
await body.dump(); // force consumption of body
|
||||
|
||||
// Do not
|
||||
const { headers } = await request(url);
|
||||
```
|
||||
|
||||
#### Forbidden and Safelisted Header Names
|
||||
|
||||
* https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name
|
||||
* https://fetch.spec.whatwg.org/#forbidden-header-name
|
||||
* https://fetch.spec.whatwg.org/#forbidden-response-header-name
|
||||
* https://github.com/wintercg/fetch/issues/6
|
||||
|
||||
The [Fetch Standard](https://fetch.spec.whatwg.org) requires implementations to exclude certain headers from requests and responses. In browser environments, some headers are forbidden so the user agent remains in full control over them. In Undici, these constraints are removed to give more control to the user.
|
||||
|
||||
#### Content-Encoding
|
||||
|
||||
* https://www.rfc-editor.org/rfc/rfc9110#field.content-encoding
|
||||
|
||||
Undici limits the number of `Content-Encoding` layers in a response to **5** to prevent resource exhaustion attacks. If a server responds with more than 5 content-encodings (e.g., `Content-Encoding: gzip, gzip, gzip, gzip, gzip, gzip`), the fetch will be rejected with an error. This limit matches the approach taken by [curl](https://curl.se/docs/CVE-2022-32206.html) and [urllib3](https://github.com/advisories/GHSA-gm62-xv2j-4rw9).
|
||||
|
||||
#### `undici.upgrade([url, options])`
|
||||
|
||||
Upgrade to a different protocol. See [MDN - HTTP - Protocol upgrade mechanism](https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism) for more details.
|
||||
|
||||
* `url` {string|URL|UrlObject}
|
||||
* `options` {UpgradeOptions} See [`UpgradeOptions`](./docs/docs/api/Dispatcher.md#parameter-upgradeoptions).
|
||||
* `dispatcher` {Dispatcher} **Default:** [getGlobalDispatcher](#undicigetglobaldispatcher).
|
||||
* `callback` {Function} (optional) — `(error, data) => void`.
|
||||
* Returns: {Promise} A promise with the result of the `Dispatcher.upgrade` method.
|
||||
|
||||
Calls `options.dispatcher.upgrade(options)`.
|
||||
|
||||
See [Dispatcher.upgrade](./docs/docs/api/Dispatcher.md#dispatcherupgradeoptions-callback) for more details.
|
||||
|
||||
### `undici.setGlobalDispatcher(dispatcher)`
|
||||
|
||||
* `dispatcher` {Dispatcher}
|
||||
|
||||
Sets the global dispatcher used by Common API Methods. Global dispatcher is shared among compatible undici modules,
|
||||
including undici that is bundled internally with node.js.
|
||||
|
||||
Undici stores this dispatcher under `Symbol.for('undici.globalDispatcher.2')`.
|
||||
|
||||
`setGlobalDispatcher()` also mirrors the configured dispatcher to
|
||||
`Symbol.for('undici.globalDispatcher.1')` using `Dispatcher1Wrapper`, so Node.js built-in `fetch`
|
||||
can keep using the legacy handler contract while Undici uses the new handler API.
|
||||
|
||||
### `undici.getGlobalDispatcher()`
|
||||
|
||||
Gets the global dispatcher used by Common API Methods.
|
||||
|
||||
* Returns: {Dispatcher}
|
||||
|
||||
### `undici.setGlobalOrigin(origin)`
|
||||
|
||||
* `origin` {string|URL|undefined}
|
||||
|
||||
Sets the global origin used in `fetch`.
|
||||
|
||||
If `undefined` is passed, the global origin will be reset. This will cause `Response.redirect`, `new Request()`, and `fetch` to throw an error when a relative path is passed.
|
||||
|
||||
```js
|
||||
setGlobalOrigin('http://localhost:3000')
|
||||
|
||||
const response = await fetch('/api/ping')
|
||||
|
||||
console.log(response.url) // http://localhost:3000/api/ping
|
||||
```
|
||||
|
||||
### `undici.getGlobalOrigin()`
|
||||
|
||||
Gets the global origin used in `fetch`.
|
||||
|
||||
* Returns: {URL}
|
||||
|
||||
### `UrlObject`
|
||||
|
||||
* `port` {string|number} (optional)
|
||||
* `path` {string} (optional)
|
||||
* `pathname` {string} (optional)
|
||||
* `hostname` {string} (optional)
|
||||
* `origin` {string} (optional)
|
||||
* `protocol` {string} (optional)
|
||||
* `search` {string} (optional)
|
||||
|
||||
#### Expect
|
||||
|
||||
Undici does not support the `Expect` request header field. The request
|
||||
body is always immediately sent and the `100 Continue` response will be
|
||||
ignored.
|
||||
|
||||
Refs: https://tools.ietf.org/html/rfc7231#section-5.1.1
|
||||
|
||||
#### Pipelining
|
||||
|
||||
Undici will only use pipelining if configured with a `pipelining` factor
|
||||
greater than `1`. Only enable pipelining when the remote server is trusted.
|
||||
Also it is important to pass `blocking: false` to the request options to
|
||||
properly pipeline requests.
|
||||
|
||||
Undici always assumes that connections are persistent and will immediately
|
||||
pipeline requests, without checking whether the connection is persistent.
|
||||
Hence, automatic fallback to HTTP/1.0 or HTTP/1.1 without pipelining is
|
||||
not supported.
|
||||
|
||||
Undici will immediately pipeline when retrying requests after a failed
|
||||
connection. However, Undici will not retry the first remaining requests in
|
||||
the prior pipeline and instead error the corresponding callback/promise/stream.
|
||||
|
||||
Undici will abort all running requests in the pipeline when any of them are
|
||||
aborted.
|
||||
|
||||
* Refs: https://tools.ietf.org/html/rfc2616#section-8.1.2.2
|
||||
* Refs: https://tools.ietf.org/html/rfc7230#section-6.3.2
|
||||
|
||||
#### Manual Redirect
|
||||
|
||||
Since it is not possible to manually follow an HTTP redirect on the server-side,
|
||||
Undici returns the actual response instead of an `opaqueredirect` filtered one
|
||||
when invoked with a `manual` redirect. This aligns `fetch()` with the other
|
||||
implementations in Deno and Cloudflare Workers.
|
||||
|
||||
Refs: https://fetch.spec.whatwg.org/#atomic-http-redirect-handling
|
||||
|
||||
### Workarounds
|
||||
|
||||
#### Network address family autoselection.
|
||||
|
||||
If you experience problem when connecting to a remote server that is resolved by your DNS servers to a IPv6 (AAAA record)
|
||||
first, there are chances that your local router or ISP might have problem connecting to IPv6 networks. In that case
|
||||
undici will throw an error with code `UND_ERR_CONNECT_TIMEOUT`.
|
||||
|
||||
If the target server resolves to both a IPv6 and IPv4 (A records) address and you are using a compatible Node version
|
||||
(18.3.0 and above), you can fix the problem by providing the `autoSelectFamily` option (support by both `undici.request`
|
||||
and `undici.Agent`) which will enable the family autoselection algorithm when establishing the connection.
|
||||
|
||||
## Collaborators
|
||||
|
||||
* [__Daniele Belardi__](https://github.com/dnlup), <https://www.npmjs.com/~dnlup>
|
||||
* [__Ethan Arrowood__](https://github.com/ethan-arrowood), <https://www.npmjs.com/~ethan_arrowood>
|
||||
* [__Matteo Collina__](https://github.com/mcollina), <https://www.npmjs.com/~matteo.collina>
|
||||
* [__Matthew Aitken__](https://github.com/KhafraDev), <https://www.npmjs.com/~khaf>
|
||||
* [__Robert Nagy__](https://github.com/ronag), <https://www.npmjs.com/~ronag>
|
||||
* [__Szymon Marczak__](https://github.com/szmarczak), <https://www.npmjs.com/~szmarczak>
|
||||
|
||||
## Past Collaborators
|
||||
* [__Tomas Della Vedova__](https://github.com/delvedor), <https://www.npmjs.com/~delvedor>
|
||||
|
||||
### Releasers
|
||||
|
||||
* [__Ethan Arrowood__](https://github.com/ethan-arrowood), <https://www.npmjs.com/~ethan_arrowood>
|
||||
* [__Matteo Collina__](https://github.com/mcollina), <https://www.npmjs.com/~matteo.collina>
|
||||
* [__Robert Nagy__](https://github.com/ronag), <https://www.npmjs.com/~ronag>
|
||||
* [__Matthew Aitken__](https://github.com/KhafraDev), <https://www.npmjs.com/~khaf>
|
||||
|
||||
## Long Term Support
|
||||
|
||||
Undici aligns with the Node.js LTS schedule. The following table shows the supported versions:
|
||||
|
||||
| Undici Version | Bundled in Node.js | Node.js Versions Supported | End of Life |
|
||||
|----------------|--------------------|----------------------------|-------------|
|
||||
| 5.x | 18.x | ≥14.0 (tested: 14, 16, 18) | 2024-04-30 |
|
||||
| 6.x | 20.x, 22.x | ≥18.17 (tested: 18, 20, 21, 22) | 2027-04-30 |
|
||||
| 7.x | 24.x | ≥20.18.1 (tested: 20, 22, 24) | 2028-04-30 |
|
||||
| 8.x | 26.x | ≥22.19.0 (tested: 22, 24, 26) | 2029-04-30 |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"sidebar": [
|
||||
{
|
||||
"groupName": "Documentation",
|
||||
"items": [
|
||||
{ "link": "/", "label": "Home" },
|
||||
{ "link": "/getting-started", "label": "Getting Started" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "API",
|
||||
"items": [
|
||||
{
|
||||
"label": "Core",
|
||||
"items": [
|
||||
{ "link": "/api/Dispatcher", "label": "Dispatcher" },
|
||||
{ "link": "/api/Client", "label": "Client" },
|
||||
{ "link": "/api/H2CClient", "label": "H2CClient" },
|
||||
{ "link": "/api/Pool", "label": "Pool" },
|
||||
{ "link": "/api/BalancedPool", "label": "BalancedPool" },
|
||||
{ "link": "/api/RoundRobinPool", "label": "RoundRobinPool" },
|
||||
{ "link": "/api/Agent", "label": "Agent" },
|
||||
{ "link": "/api/PoolStats", "label": "PoolStats" },
|
||||
{ "link": "/api/Connector", "label": "Connector" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Proxy & Agents",
|
||||
"items": [
|
||||
{ "link": "/api/ProxyAgent", "label": "ProxyAgent" },
|
||||
{
|
||||
"link": "/api/EnvHttpProxyAgent",
|
||||
"label": "EnvHttpProxyAgent"
|
||||
},
|
||||
{ "link": "/api/Socks5ProxyAgent", "label": "Socks5ProxyAgent" },
|
||||
{ "link": "/api/RetryAgent", "label": "RetryAgent" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Web Standards",
|
||||
"items": [
|
||||
{ "link": "/api/Fetch", "label": "Fetch" },
|
||||
{ "link": "/api/EventSource", "label": "EventSource" },
|
||||
{ "link": "/api/WebSocket", "label": "WebSocket" },
|
||||
{ "link": "/api/Cookies", "label": "Cookies" },
|
||||
{ "link": "/api/CacheStorage", "label": "CacheStorage" },
|
||||
{ "link": "/api/ContentType", "label": "MIME Type Parsing" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Mocking",
|
||||
"items": [
|
||||
{ "link": "/api/MockClient", "label": "MockClient" },
|
||||
{ "link": "/api/MockPool", "label": "MockPool" },
|
||||
{ "link": "/api/MockAgent", "label": "MockAgent" },
|
||||
{ "link": "/api/SnapshotAgent", "label": "SnapshotAgent" },
|
||||
{ "link": "/api/MockCallHistory", "label": "MockCallHistory" },
|
||||
{
|
||||
"link": "/api/MockCallHistoryLog",
|
||||
"label": "MockCallHistoryLog"
|
||||
},
|
||||
{ "link": "/api/MockErrors", "label": "MockErrors" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Interceptors",
|
||||
"items": [
|
||||
{ "link": "/api/Interceptors", "label": "Interceptors" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Handlers",
|
||||
"items": [
|
||||
{ "link": "/api/RedirectHandler", "label": "RedirectHandler" },
|
||||
{ "link": "/api/RetryHandler", "label": "RetryHandler" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Diagnostics & Utilities",
|
||||
"items": [
|
||||
{ "link": "/api/Errors", "label": "Errors" },
|
||||
{
|
||||
"link": "/api/DiagnosticsChannel",
|
||||
"label": "Diagnostics Channel Support"
|
||||
},
|
||||
{ "link": "/api/Debug", "label": "Debug" },
|
||||
{ "link": "/api/Util", "label": "Util" },
|
||||
{
|
||||
"link": "/api/GlobalInstallation",
|
||||
"label": "Global Installation"
|
||||
},
|
||||
{ "link": "/api/api-lifecycle", "label": "API Lifecycle" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "Best Practices",
|
||||
"items": [
|
||||
{
|
||||
"link": "/best-practices/undici-vs-builtin-fetch",
|
||||
"label": "Undici vs. Built-in Fetch"
|
||||
},
|
||||
{
|
||||
"link": "/best-practices/migrating-from-v7-to-v8",
|
||||
"label": "Migrating from Undici 7 to 8"
|
||||
},
|
||||
{ "link": "/best-practices/proxy", "label": "Proxy" },
|
||||
{
|
||||
"link": "/best-practices/client-certificate",
|
||||
"label": "Client Certificate"
|
||||
},
|
||||
{
|
||||
"link": "/best-practices/writing-tests",
|
||||
"label": "Writing Tests"
|
||||
},
|
||||
{
|
||||
"link": "/best-practices/mocking-request",
|
||||
"label": "Mocking Request"
|
||||
},
|
||||
{ "link": "/best-practices/crawling", "label": "Crawling" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"AbortError": "/api/Errors.md#class-aborterror",
|
||||
"Agent": "/api/Agent.md#class-agent",
|
||||
"AgentDispatchOptions": "/api/Agent.md#agentdispatchoptions-handler",
|
||||
"AgentOptions": "/api/Agent.md#new-agentoptions",
|
||||
"BalancedPool": "/api/BalancedPool.md#class-balancedpool",
|
||||
"BalancedPoolOptions": "/api/BalancedPool.md#parameter-balancedpooloptions",
|
||||
"BodyMixin": "/api/Fetch.md#new-formdata",
|
||||
"Buffer": "https://nodejs.org/api/buffer.html#class-buffer",
|
||||
"BuildOptions": "/api/Connector.md#buildconnectoroptions",
|
||||
"CacheKey": "/api/CacheStore.md#cachekey",
|
||||
"CacheStore": "/api/CacheStore.md#new-memorycachestoreoptions",
|
||||
"CacheValue": "/api/CacheStore.md#cachevalue",
|
||||
"ClientDestroyedError": "/api/Errors.md#class-clientdestroyederror",
|
||||
"ClientOptions": "/api/Client.md#new-clienturl-options",
|
||||
"ClientStats": "/api/ClientStats.md#class-clientstats",
|
||||
"ConnectOptions": "/api/Dispatcher.md#class-dispatcher",
|
||||
"Cookie": "/api/Cookies.md#cookie",
|
||||
"DispatchController": "/api/Dispatcher.md#class-dispatcher",
|
||||
"DispatchHandler": "/api/Dispatcher.md#class-dispatcher",
|
||||
"DispatchOptions": "/api/Dispatcher.md#class-dispatcher",
|
||||
"Dispatcher": "/api/Dispatcher.md#class-dispatcher",
|
||||
"Duplex": "https://nodejs.org/api/stream.html#class-streamduplex",
|
||||
"EnvHttpProxyAgent": "/api/EnvHttpProxyAgent.md#class-envhttpproxyagent",
|
||||
"EnvHttpProxyAgentOptions": "/api/EnvHttpProxyAgent.md#new-envhttpproxyagentoptions",
|
||||
"EventEmitter": "https://nodejs.org/api/events.html#class-eventemitter",
|
||||
"GetResult": "/api/CacheStore.md#getresult",
|
||||
"H2CClientOptions": "/api/H2CClient.md#new-h2cclienturl-options",
|
||||
"HeadersInit": "https://developer.mozilla.org/docs/Web/API/Headers/Headers",
|
||||
"InvalidArgumentError": "/api/Errors.md#class-invalidargumenterror",
|
||||
"Iterable": "https://developer.mozilla.org/docs/Web/JavaScript/Reference/Iteration_protocols",
|
||||
"MaxOriginsReachedError": "/api/Errors.md#class-maxoriginsreachederror",
|
||||
"MockAgent": "/api/MockAgent.md#class-mockagent",
|
||||
"MockAgentOptions": "/api/MockAgent.md#parameter-mockagentoptions",
|
||||
"MockCallHistory": "/api/MockCallHistory.md#class-mockcallhistory",
|
||||
"MockCallHistoryLog": "/api/MockCallHistoryLog.md#class-mockcallhistorylog",
|
||||
"MockClient": "/api/MockClient.md#class-mockclient",
|
||||
"MockClientOptions": "/api/MockClient.md#parameter-mockclientoptions",
|
||||
"MockInterceptor": "/api/MockPool.md#return-mockinterceptor",
|
||||
"MockInterceptor.Options": "/api/MockPool.md#parameter-mockpoolinterceptoptions",
|
||||
"MockPool": "/api/MockPool.md#class-mockpool",
|
||||
"MockPoolInterceptOptions": "/api/MockPool.md#parameter-mockpoolinterceptoptions",
|
||||
"MockPoolOptions": "/api/MockPool.md#parameter-mockpooloptions",
|
||||
"MockResponseOptions": "/api/MockPool.md#parameter-mockresponseoptions",
|
||||
"MockScope": "/api/MockPool.md#return-mockscope",
|
||||
"PendingInterceptor": "/api/MockAgent.md#new-mockagentoptions",
|
||||
"PipelineOptions": "/api/Dispatcher.md#class-dispatcher",
|
||||
"Pool": "/api/Pool.md#class-pool",
|
||||
"PoolOptions": "/api/Pool.md#new-poolurl-options",
|
||||
"PoolStats": "/api/PoolStats.md#class-poolstats",
|
||||
"ProxyAgent": "/api/ProxyAgent.md#class-proxyagent",
|
||||
"ProxyAgentOptions": "/api/ProxyAgent.md#new-proxyagentoptions",
|
||||
"Readable": "https://nodejs.org/api/stream.html#class-streamreadable",
|
||||
"Record": "https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeys-type",
|
||||
"RequestInit": "/api/Fetch.md#new-formdata",
|
||||
"RequestOptions": "/api/Dispatcher.md#class-dispatcher",
|
||||
"ResponseInit": "/api/Fetch.md#new-formdata",
|
||||
"RetryContext": "/api/RetryHandler.md#retrycontext",
|
||||
"RetryHandler": "/api/RetryHandler.md#class-retryhandler",
|
||||
"RetryHandlers": "/api/RetryHandler.md#parameter-retryhandlers",
|
||||
"RetryOptions": "/api/RetryHandler.md#retryoptions",
|
||||
"RetryState": "/api/RetryHandler.md#retrystate",
|
||||
"RoundRobinPoolOptions": "/api/RoundRobinPool.md#parameter-roundrobinpooloptions",
|
||||
"SnapshotRecorder": "/api/SnapshotAgent.md#new-snapshotagentoptions",
|
||||
"Socket": "https://nodejs.org/api/net.html#class-netsocket",
|
||||
"SocketInfo": "/api/Errors.md#new-requestretryerrormessage-code-options",
|
||||
"StreamOptions": "/api/Dispatcher.md#class-dispatcher",
|
||||
"TLSSocket": "https://nodejs.org/api/tls.html#class-tlstlssocket",
|
||||
"UndiciError": "/api/Errors.md#class-undicierror",
|
||||
"UndiciHeaders": "/api/Dispatcher.md#class-dispatcher",
|
||||
"UpgradeOptions": "/api/Dispatcher.md#class-dispatcher",
|
||||
"UrlObject": "/index.md#urlobject",
|
||||
"WebSocketInit": "/api/WebSocket.md#new-websocketurl-protocols",
|
||||
"WebSocketStreamOptions": "/api/WebSocket.md#new-websocketurl-protocols",
|
||||
"Writable": "https://nodejs.org/api/stream.html#class-streamwritable",
|
||||
"buildConnector.BuildOptions": "/api/Connector.md#buildconnectoroptions",
|
||||
"net.Socket": "https://nodejs.org/api/net.html#class-netsocket",
|
||||
"stream.Readable": "https://nodejs.org/api/stream.html#class-streamreadable"
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
'use strict'
|
||||
|
||||
const { getGlobalDispatcher, setGlobalDispatcher } = require('./lib/global')
|
||||
const EnvHttpProxyAgent = require('./lib/dispatcher/env-http-proxy-agent')
|
||||
const fetchImpl = require('./lib/web/fetch').fetch
|
||||
|
||||
// Capture __filename at module load time for stack trace augmentation.
|
||||
// This may be undefined when bundled in environments like Node.js internals.
|
||||
const currentFilename = typeof __filename !== 'undefined' ? __filename : undefined
|
||||
|
||||
function appendFetchStackTrace (err, filename) {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const stack = typeof err.stack === 'string' ? err.stack : ''
|
||||
const normalizedFilename = filename.replace(/\\/g, '/')
|
||||
|
||||
if (stack && (stack.includes(filename) || stack.includes(normalizedFilename))) {
|
||||
return
|
||||
}
|
||||
|
||||
const capture = {}
|
||||
Error.captureStackTrace(capture, appendFetchStackTrace)
|
||||
|
||||
if (!capture.stack) {
|
||||
return
|
||||
}
|
||||
|
||||
const captureLines = capture.stack.split('\n').slice(1).join('\n')
|
||||
|
||||
err.stack = stack ? `${stack}\n${captureLines}` : capture.stack
|
||||
}
|
||||
|
||||
module.exports.fetch = function fetch (init, options = undefined) {
|
||||
return fetchImpl(init, options).catch(err => {
|
||||
if (currentFilename) {
|
||||
appendFetchStackTrace(err, currentFilename)
|
||||
} else if (err && typeof err === 'object') {
|
||||
Error.captureStackTrace(err, module.exports.fetch)
|
||||
}
|
||||
throw err
|
||||
})
|
||||
}
|
||||
module.exports.FormData = require('./lib/web/fetch/formdata').FormData
|
||||
module.exports.Headers = require('./lib/web/fetch/headers').Headers
|
||||
module.exports.Response = require('./lib/web/fetch/response').Response
|
||||
module.exports.Request = require('./lib/web/fetch/request').Request
|
||||
|
||||
const { CloseEvent, ErrorEvent, MessageEvent, createFastMessageEvent } = require('./lib/web/websocket/events')
|
||||
module.exports.WebSocket = require('./lib/web/websocket/websocket').WebSocket
|
||||
module.exports.CloseEvent = CloseEvent
|
||||
module.exports.ErrorEvent = ErrorEvent
|
||||
module.exports.MessageEvent = MessageEvent
|
||||
module.exports.createFastMessageEvent = createFastMessageEvent
|
||||
|
||||
module.exports.EventSource = require('./lib/web/eventsource/eventsource').EventSource
|
||||
|
||||
const api = require('./lib/api')
|
||||
const Dispatcher = require('./lib/dispatcher/dispatcher')
|
||||
Object.assign(Dispatcher.prototype, api)
|
||||
// Expose the fetch implementation to be enabled in Node.js core via a flag
|
||||
module.exports.EnvHttpProxyAgent = EnvHttpProxyAgent
|
||||
module.exports.getGlobalDispatcher = getGlobalDispatcher
|
||||
module.exports.setGlobalDispatcher = setGlobalDispatcher
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import Undici from './types/index'
|
||||
export default Undici
|
||||
export * from './types/index'
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
'use strict'
|
||||
|
||||
const Client = require('./lib/dispatcher/client')
|
||||
const Dispatcher = require('./lib/dispatcher/dispatcher')
|
||||
const Pool = require('./lib/dispatcher/pool')
|
||||
const BalancedPool = require('./lib/dispatcher/balanced-pool')
|
||||
const RoundRobinPool = require('./lib/dispatcher/round-robin-pool')
|
||||
const Agent = require('./lib/dispatcher/agent')
|
||||
const Dispatcher1Wrapper = require('./lib/dispatcher/dispatcher1-wrapper')
|
||||
const ProxyAgent = require('./lib/dispatcher/proxy-agent')
|
||||
const Socks5ProxyAgent = require('./lib/dispatcher/socks5-proxy-agent')
|
||||
const EnvHttpProxyAgent = require('./lib/dispatcher/env-http-proxy-agent')
|
||||
const RetryAgent = require('./lib/dispatcher/retry-agent')
|
||||
const H2CClient = require('./lib/dispatcher/h2c-client')
|
||||
const errors = require('./lib/core/errors')
|
||||
const util = require('./lib/core/util')
|
||||
const { InvalidArgumentError } = errors
|
||||
const api = require('./lib/api')
|
||||
const buildConnector = require('./lib/core/connect')
|
||||
const MockClient = require('./lib/mock/mock-client')
|
||||
const { MockCallHistory, MockCallHistoryLog } = require('./lib/mock/mock-call-history')
|
||||
const MockAgent = require('./lib/mock/mock-agent')
|
||||
const MockPool = require('./lib/mock/mock-pool')
|
||||
const SnapshotAgent = require('./lib/mock/snapshot-agent')
|
||||
const mockErrors = require('./lib/mock/mock-errors')
|
||||
const RetryHandler = require('./lib/handler/retry-handler')
|
||||
const { getGlobalDispatcher, setGlobalDispatcher } = require('./lib/global')
|
||||
const DecoratorHandler = require('./lib/handler/decorator-handler')
|
||||
const RedirectHandler = require('./lib/handler/redirect-handler')
|
||||
|
||||
Object.assign(Dispatcher.prototype, api)
|
||||
|
||||
module.exports.Dispatcher = Dispatcher
|
||||
module.exports.Client = Client
|
||||
module.exports.Pool = Pool
|
||||
module.exports.BalancedPool = BalancedPool
|
||||
module.exports.RoundRobinPool = RoundRobinPool
|
||||
module.exports.Agent = Agent
|
||||
module.exports.Dispatcher1Wrapper = Dispatcher1Wrapper
|
||||
module.exports.ProxyAgent = ProxyAgent
|
||||
module.exports.Socks5ProxyAgent = Socks5ProxyAgent
|
||||
module.exports.EnvHttpProxyAgent = EnvHttpProxyAgent
|
||||
module.exports.RetryAgent = RetryAgent
|
||||
module.exports.H2CClient = H2CClient
|
||||
module.exports.RetryHandler = RetryHandler
|
||||
|
||||
module.exports.DecoratorHandler = DecoratorHandler
|
||||
module.exports.RedirectHandler = RedirectHandler
|
||||
module.exports.interceptors = {
|
||||
redirect: require('./lib/interceptor/redirect'),
|
||||
responseError: require('./lib/interceptor/response-error'),
|
||||
retry: require('./lib/interceptor/retry'),
|
||||
dump: require('./lib/interceptor/dump'),
|
||||
dns: require('./lib/interceptor/dns'),
|
||||
cache: require('./lib/interceptor/cache'),
|
||||
decompress: require('./lib/interceptor/decompress'),
|
||||
deduplicate: require('./lib/interceptor/deduplicate')
|
||||
}
|
||||
|
||||
module.exports.cacheStores = {
|
||||
MemoryCacheStore: require('./lib/cache/memory-cache-store')
|
||||
}
|
||||
|
||||
const SqliteCacheStore = require('./lib/cache/sqlite-cache-store')
|
||||
module.exports.cacheStores.SqliteCacheStore = SqliteCacheStore
|
||||
|
||||
module.exports.buildConnector = buildConnector
|
||||
module.exports.errors = errors
|
||||
module.exports.util = {
|
||||
parseHeaders: util.parseHeaders,
|
||||
headerNameToString: util.headerNameToString
|
||||
}
|
||||
|
||||
function makeDispatcher (fn) {
|
||||
return (url, opts, handler) => {
|
||||
if (typeof opts === 'function') {
|
||||
handler = opts
|
||||
opts = null
|
||||
}
|
||||
|
||||
if (!url || (typeof url !== 'string' && typeof url !== 'object' && !(url instanceof URL))) {
|
||||
throw new InvalidArgumentError('invalid url')
|
||||
}
|
||||
|
||||
if (opts != null && typeof opts !== 'object') {
|
||||
throw new InvalidArgumentError('invalid opts')
|
||||
}
|
||||
|
||||
if (opts && opts.path != null) {
|
||||
if (typeof opts.path !== 'string') {
|
||||
throw new InvalidArgumentError('invalid opts.path')
|
||||
}
|
||||
|
||||
let path = opts.path
|
||||
if (!opts.path.startsWith('/')) {
|
||||
path = `/${path}`
|
||||
}
|
||||
|
||||
url = new URL(util.parseOrigin(url).origin + path)
|
||||
} else {
|
||||
if (!opts) {
|
||||
opts = typeof url === 'object' ? url : {}
|
||||
}
|
||||
|
||||
url = util.parseURL(url)
|
||||
}
|
||||
|
||||
const { agent, dispatcher = getGlobalDispatcher(), ...restOpts } = opts
|
||||
|
||||
if (agent) {
|
||||
throw new InvalidArgumentError('unsupported opts.agent. Did you mean opts.client?')
|
||||
}
|
||||
|
||||
return fn.call(dispatcher, {
|
||||
...restOpts,
|
||||
origin: url.origin,
|
||||
path: url.search ? `${url.pathname}${url.search}` : url.pathname,
|
||||
method: opts.method || (opts.body ? 'PUT' : 'GET')
|
||||
}, handler)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.setGlobalDispatcher = setGlobalDispatcher
|
||||
module.exports.getGlobalDispatcher = getGlobalDispatcher
|
||||
|
||||
const fetchImpl = require('./lib/web/fetch').fetch
|
||||
|
||||
// Capture __filename at module load time for stack trace augmentation.
|
||||
// This may be undefined when bundled in environments like Node.js internals.
|
||||
const currentFilename = typeof __filename !== 'undefined' ? __filename : undefined
|
||||
|
||||
function appendFetchStackTrace (err, filename) {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const stack = typeof err.stack === 'string' ? err.stack : ''
|
||||
const normalizedFilename = filename.replace(/\\/g, '/')
|
||||
|
||||
if (stack && (stack.includes(filename) || stack.includes(normalizedFilename))) {
|
||||
return
|
||||
}
|
||||
|
||||
const capture = {}
|
||||
Error.captureStackTrace(capture, appendFetchStackTrace)
|
||||
|
||||
if (!capture.stack) {
|
||||
return
|
||||
}
|
||||
|
||||
const captureLines = capture.stack.split('\n').slice(1).join('\n')
|
||||
|
||||
err.stack = stack ? `${stack}\n${captureLines}` : capture.stack
|
||||
}
|
||||
|
||||
module.exports.fetch = function fetch (init, options = undefined) {
|
||||
return fetchImpl(init, options).catch(err => {
|
||||
if (currentFilename) {
|
||||
appendFetchStackTrace(err, currentFilename)
|
||||
} else if (err && typeof err === 'object') {
|
||||
Error.captureStackTrace(err, module.exports.fetch)
|
||||
}
|
||||
throw err
|
||||
})
|
||||
}
|
||||
module.exports.Headers = require('./lib/web/fetch/headers').Headers
|
||||
module.exports.Response = require('./lib/web/fetch/response').Response
|
||||
module.exports.Request = require('./lib/web/fetch/request').Request
|
||||
module.exports.FormData = require('./lib/web/fetch/formdata').FormData
|
||||
|
||||
const { setGlobalOrigin, getGlobalOrigin } = require('./lib/web/fetch/global')
|
||||
|
||||
module.exports.setGlobalOrigin = setGlobalOrigin
|
||||
module.exports.getGlobalOrigin = getGlobalOrigin
|
||||
|
||||
const { CacheStorage } = require('./lib/web/cache/cachestorage')
|
||||
const { kConstruct } = require('./lib/core/symbols')
|
||||
|
||||
module.exports.caches = new CacheStorage(kConstruct)
|
||||
|
||||
const { deleteCookie, getCookies, getSetCookies, setCookie, parseCookie } = require('./lib/web/cookies')
|
||||
|
||||
module.exports.deleteCookie = deleteCookie
|
||||
module.exports.getCookies = getCookies
|
||||
module.exports.getSetCookies = getSetCookies
|
||||
module.exports.setCookie = setCookie
|
||||
module.exports.parseCookie = parseCookie
|
||||
|
||||
const { parseMIMEType, serializeAMimeType } = require('./lib/web/fetch/data-url')
|
||||
|
||||
module.exports.parseMIMEType = parseMIMEType
|
||||
module.exports.serializeAMimeType = serializeAMimeType
|
||||
|
||||
const { CloseEvent, ErrorEvent, MessageEvent } = require('./lib/web/websocket/events')
|
||||
const { WebSocket, ping } = require('./lib/web/websocket/websocket')
|
||||
module.exports.WebSocket = WebSocket
|
||||
module.exports.CloseEvent = CloseEvent
|
||||
module.exports.ErrorEvent = ErrorEvent
|
||||
module.exports.MessageEvent = MessageEvent
|
||||
module.exports.ping = ping
|
||||
|
||||
module.exports.WebSocketStream = require('./lib/web/websocket/stream/websocketstream').WebSocketStream
|
||||
module.exports.WebSocketError = require('./lib/web/websocket/stream/websocketerror').WebSocketError
|
||||
|
||||
module.exports.request = makeDispatcher(api.request)
|
||||
module.exports.stream = makeDispatcher(api.stream)
|
||||
module.exports.pipeline = makeDispatcher(api.pipeline)
|
||||
module.exports.connect = makeDispatcher(api.connect)
|
||||
module.exports.upgrade = makeDispatcher(api.upgrade)
|
||||
|
||||
module.exports.MockClient = MockClient
|
||||
module.exports.MockCallHistory = MockCallHistory
|
||||
module.exports.MockCallHistoryLog = MockCallHistoryLog
|
||||
module.exports.MockPool = MockPool
|
||||
module.exports.MockAgent = MockAgent
|
||||
module.exports.SnapshotAgent = SnapshotAgent
|
||||
module.exports.mockErrors = mockErrors
|
||||
|
||||
const { EventSource } = require('./lib/web/eventsource/eventsource')
|
||||
|
||||
module.exports.EventSource = EventSource
|
||||
|
||||
function install () {
|
||||
globalThis.fetch = module.exports.fetch
|
||||
globalThis.Headers = module.exports.Headers
|
||||
globalThis.Response = module.exports.Response
|
||||
globalThis.Request = module.exports.Request
|
||||
globalThis.FormData = module.exports.FormData
|
||||
globalThis.WebSocket = module.exports.WebSocket
|
||||
globalThis.CloseEvent = module.exports.CloseEvent
|
||||
globalThis.ErrorEvent = module.exports.ErrorEvent
|
||||
globalThis.MessageEvent = module.exports.MessageEvent
|
||||
globalThis.EventSource = module.exports.EventSource
|
||||
}
|
||||
|
||||
module.exports.install = install
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
'use strict'
|
||||
|
||||
const { addAbortListener } = require('../core/util')
|
||||
const { RequestAbortedError } = require('../core/errors')
|
||||
|
||||
const kListener = Symbol('kListener')
|
||||
const kSignal = Symbol('kSignal')
|
||||
|
||||
function abort (self) {
|
||||
if (self.abort) {
|
||||
self.abort(self[kSignal]?.reason)
|
||||
} else {
|
||||
self.reason = self[kSignal]?.reason ?? new RequestAbortedError()
|
||||
}
|
||||
removeSignal(self)
|
||||
}
|
||||
|
||||
function addSignal (self, signal) {
|
||||
self.reason = null
|
||||
|
||||
self[kSignal] = null
|
||||
self[kListener] = null
|
||||
|
||||
if (!signal) {
|
||||
return
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
abort(self)
|
||||
return
|
||||
}
|
||||
|
||||
self[kSignal] = signal
|
||||
self[kListener] = () => {
|
||||
abort(self)
|
||||
}
|
||||
|
||||
addAbortListener(self[kSignal], self[kListener])
|
||||
}
|
||||
|
||||
function removeSignal (self) {
|
||||
if (!self[kSignal]) {
|
||||
return
|
||||
}
|
||||
|
||||
if ('removeEventListener' in self[kSignal]) {
|
||||
self[kSignal].removeEventListener('abort', self[kListener])
|
||||
} else {
|
||||
self[kSignal].removeListener('abort', self[kListener])
|
||||
}
|
||||
|
||||
self[kSignal] = null
|
||||
self[kListener] = null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addSignal,
|
||||
removeSignal
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert')
|
||||
const { AsyncResource } = require('node:async_hooks')
|
||||
const { InvalidArgumentError, SocketError } = require('../core/errors')
|
||||
const util = require('../core/util')
|
||||
const { addSignal, removeSignal } = require('./abort-signal')
|
||||
|
||||
class ConnectHandler extends AsyncResource {
|
||||
constructor (opts, callback) {
|
||||
if (!opts || typeof opts !== 'object') {
|
||||
throw new InvalidArgumentError('invalid opts')
|
||||
}
|
||||
|
||||
if (typeof callback !== 'function') {
|
||||
throw new InvalidArgumentError('invalid callback')
|
||||
}
|
||||
|
||||
const { signal, opaque, responseHeaders } = opts
|
||||
|
||||
if (signal && typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') {
|
||||
throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget')
|
||||
}
|
||||
|
||||
super('UNDICI_CONNECT')
|
||||
|
||||
this.opaque = opaque || null
|
||||
this.responseHeaders = responseHeaders || null
|
||||
this.callback = callback
|
||||
this.abort = null
|
||||
|
||||
addSignal(this, signal)
|
||||
}
|
||||
|
||||
onRequestStart (controller, context) {
|
||||
if (this.reason) {
|
||||
controller.abort(this.reason)
|
||||
return
|
||||
}
|
||||
|
||||
assert(this.callback)
|
||||
|
||||
this.abort = (reason) => controller.abort(reason)
|
||||
this.context = context
|
||||
}
|
||||
|
||||
onResponseStart () {
|
||||
throw new SocketError('bad connect', null)
|
||||
}
|
||||
|
||||
onRequestUpgrade (controller, statusCode, headers, socket) {
|
||||
const { callback, opaque, context } = this
|
||||
|
||||
removeSignal(this)
|
||||
|
||||
this.callback = null
|
||||
|
||||
let responseHeaders = headers
|
||||
const rawHeaders = controller?.rawHeaders
|
||||
// Indicates is an HTTP2Session
|
||||
if (responseHeaders != null) {
|
||||
responseHeaders = this.responseHeaders === 'raw'
|
||||
? util.parseRawHeaders(rawHeaders)
|
||||
: headers
|
||||
}
|
||||
|
||||
this.runInAsyncScope(callback, null, null, {
|
||||
statusCode,
|
||||
headers: responseHeaders,
|
||||
socket,
|
||||
opaque,
|
||||
context
|
||||
})
|
||||
}
|
||||
|
||||
onResponseError (_controller, err) {
|
||||
const { callback, opaque } = this
|
||||
|
||||
removeSignal(this)
|
||||
|
||||
if (callback) {
|
||||
this.callback = null
|
||||
queueMicrotask(() => {
|
||||
this.runInAsyncScope(callback, null, err, { opaque })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connect (opts, callback) {
|
||||
if (callback === undefined) {
|
||||
return new Promise((resolve, reject) => {
|
||||
connect.call(this, opts, (err, data) => {
|
||||
return err ? reject(err) : resolve(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const connectHandler = new ConnectHandler(opts, callback)
|
||||
const connectOptions = { ...opts, method: 'CONNECT' }
|
||||
this.dispatch(connectOptions, connectHandler)
|
||||
} catch (err) {
|
||||
if (typeof callback !== 'function') {
|
||||
throw err
|
||||
}
|
||||
const opaque = opts?.opaque
|
||||
queueMicrotask(() => callback(err, { opaque }))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = connect
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
'use strict'
|
||||
|
||||
const {
|
||||
Readable,
|
||||
Duplex,
|
||||
PassThrough
|
||||
} = require('node:stream')
|
||||
const assert = require('node:assert')
|
||||
const { AsyncResource } = require('node:async_hooks')
|
||||
const {
|
||||
InvalidArgumentError,
|
||||
InvalidReturnValueError,
|
||||
RequestAbortedError
|
||||
} = require('../core/errors')
|
||||
const util = require('../core/util')
|
||||
const { kBodyUsed } = require('../core/symbols')
|
||||
const { addSignal, removeSignal } = require('./abort-signal')
|
||||
|
||||
function noop () {}
|
||||
|
||||
const kResume = Symbol('resume')
|
||||
|
||||
class PipelineRequest extends Readable {
|
||||
constructor () {
|
||||
super({ autoDestroy: true })
|
||||
|
||||
this[kResume] = null
|
||||
// Pipeline request bodies come from a live writable side and cannot be
|
||||
// replayed across redirects or retries, even before any bytes are read.
|
||||
this[kBodyUsed] = true
|
||||
}
|
||||
|
||||
_read () {
|
||||
const { [kResume]: resume } = this
|
||||
|
||||
if (resume) {
|
||||
this[kResume] = null
|
||||
resume()
|
||||
}
|
||||
}
|
||||
|
||||
_destroy (err, callback) {
|
||||
this._read()
|
||||
|
||||
callback(err)
|
||||
}
|
||||
}
|
||||
|
||||
class PipelineResponse extends Readable {
|
||||
constructor (resume) {
|
||||
super({ autoDestroy: true })
|
||||
this[kResume] = resume
|
||||
}
|
||||
|
||||
_read () {
|
||||
this[kResume]()
|
||||
}
|
||||
|
||||
_destroy (err, callback) {
|
||||
if (!err && !this._readableState.endEmitted) {
|
||||
err = new RequestAbortedError()
|
||||
}
|
||||
|
||||
callback(err)
|
||||
}
|
||||
}
|
||||
|
||||
class PipelineHandler extends AsyncResource {
|
||||
constructor (opts, handler) {
|
||||
if (!opts || typeof opts !== 'object') {
|
||||
throw new InvalidArgumentError('invalid opts')
|
||||
}
|
||||
|
||||
if (typeof handler !== 'function') {
|
||||
throw new InvalidArgumentError('invalid handler')
|
||||
}
|
||||
|
||||
const { signal, method, opaque, onInfo, responseHeaders } = opts
|
||||
|
||||
if (signal && typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') {
|
||||
throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget')
|
||||
}
|
||||
|
||||
if (method === 'CONNECT') {
|
||||
throw new InvalidArgumentError('invalid method')
|
||||
}
|
||||
|
||||
if (onInfo && typeof onInfo !== 'function') {
|
||||
throw new InvalidArgumentError('invalid onInfo callback')
|
||||
}
|
||||
|
||||
super('UNDICI_PIPELINE')
|
||||
|
||||
this.opaque = opaque || null
|
||||
this.responseHeaders = responseHeaders || null
|
||||
this.handler = handler
|
||||
this.abort = null
|
||||
this.context = null
|
||||
this.onInfo = onInfo || null
|
||||
|
||||
this.req = new PipelineRequest().on('error', noop)
|
||||
|
||||
this.ret = new Duplex({
|
||||
readableObjectMode: opts.objectMode,
|
||||
autoDestroy: true,
|
||||
read: () => {
|
||||
const { body } = this
|
||||
|
||||
if (body?.resume) {
|
||||
body.resume()
|
||||
}
|
||||
},
|
||||
write: (chunk, encoding, callback) => {
|
||||
const { req } = this
|
||||
|
||||
if (req.push(chunk, encoding) || req._readableState.destroyed) {
|
||||
callback()
|
||||
} else {
|
||||
req[kResume] = callback
|
||||
}
|
||||
},
|
||||
destroy: (err, callback) => {
|
||||
const { body, req, res, ret, abort } = this
|
||||
|
||||
if (!err && !ret._readableState.endEmitted) {
|
||||
err = new RequestAbortedError()
|
||||
}
|
||||
|
||||
if (abort && err) {
|
||||
abort()
|
||||
}
|
||||
|
||||
util.destroy(body, err)
|
||||
util.destroy(req, err)
|
||||
util.destroy(res, err)
|
||||
|
||||
removeSignal(this)
|
||||
|
||||
callback(err)
|
||||
}
|
||||
}).on('prefinish', () => {
|
||||
const { req } = this
|
||||
|
||||
// Node < 15 does not call _final in same tick.
|
||||
req.push(null)
|
||||
})
|
||||
|
||||
this.res = null
|
||||
|
||||
addSignal(this, signal)
|
||||
}
|
||||
|
||||
onRequestStart (controller, context) {
|
||||
const { res } = this
|
||||
|
||||
if (this.reason) {
|
||||
controller.abort(this.reason)
|
||||
return
|
||||
}
|
||||
|
||||
assert(!res, 'pipeline cannot be retried')
|
||||
|
||||
this.abort = (reason) => controller.abort(reason)
|
||||
this.context = context
|
||||
}
|
||||
|
||||
onResponseStart (controller, statusCode, headers, _statusMessage) {
|
||||
const { opaque, handler, context } = this
|
||||
|
||||
if (statusCode < 200) {
|
||||
if (this.onInfo) {
|
||||
const rawHeaders = controller?.rawHeaders
|
||||
const responseHeaders = this.responseHeaders === 'raw'
|
||||
? util.parseRawHeaders(rawHeaders)
|
||||
: headers
|
||||
this.onInfo({ statusCode, headers: responseHeaders })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.res = new PipelineResponse(() => controller.resume())
|
||||
|
||||
let body
|
||||
try {
|
||||
this.handler = null
|
||||
const rawHeaders = controller?.rawHeaders
|
||||
const responseHeaders = this.responseHeaders === 'raw'
|
||||
? util.parseRawHeaders(rawHeaders)
|
||||
: headers
|
||||
body = this.runInAsyncScope(handler, null, {
|
||||
statusCode,
|
||||
headers: responseHeaders,
|
||||
opaque,
|
||||
body: this.res,
|
||||
context
|
||||
})
|
||||
} catch (err) {
|
||||
this.res.on('error', noop)
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!body || typeof body.on !== 'function') {
|
||||
throw new InvalidReturnValueError('expected Readable')
|
||||
}
|
||||
|
||||
body
|
||||
.on('data', (chunk) => {
|
||||
const { ret, body } = this
|
||||
|
||||
if (!ret.push(chunk) && body.pause) {
|
||||
body.pause()
|
||||
}
|
||||
})
|
||||
.on('error', (err) => {
|
||||
const { ret } = this
|
||||
|
||||
util.destroy(ret, err)
|
||||
})
|
||||
.on('end', () => {
|
||||
const { ret } = this
|
||||
|
||||
ret.push(null)
|
||||
})
|
||||
.on('close', () => {
|
||||
const { ret } = this
|
||||
|
||||
if (!ret._readableState.ended) {
|
||||
util.destroy(ret, new RequestAbortedError())
|
||||
}
|
||||
})
|
||||
|
||||
this.body = body
|
||||
}
|
||||
|
||||
onResponseData (controller, chunk) {
|
||||
const { res } = this
|
||||
|
||||
if (res.push(chunk) === false) {
|
||||
controller.pause()
|
||||
}
|
||||
}
|
||||
|
||||
onResponseEnd (_controller, _trailers) {
|
||||
const { res } = this
|
||||
res.push(null)
|
||||
}
|
||||
|
||||
onResponseError (_controller, err) {
|
||||
const { ret } = this
|
||||
this.handler = null
|
||||
util.destroy(ret, err)
|
||||
}
|
||||
}
|
||||
|
||||
function pipeline (opts, handler) {
|
||||
try {
|
||||
const pipelineHandler = new PipelineHandler(opts, handler)
|
||||
this.dispatch({ ...opts, body: pipelineHandler.req }, pipelineHandler)
|
||||
return pipelineHandler.ret
|
||||
} catch (err) {
|
||||
return new PassThrough().destroy(err)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = pipeline
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert')
|
||||
const { AsyncResource } = require('node:async_hooks')
|
||||
const { Readable } = require('./readable')
|
||||
const { InvalidArgumentError, RequestAbortedError } = require('../core/errors')
|
||||
const util = require('../core/util')
|
||||
|
||||
function noop () {}
|
||||
|
||||
class RequestHandler extends AsyncResource {
|
||||
constructor (opts, callback) {
|
||||
if (!opts || typeof opts !== 'object') {
|
||||
throw new InvalidArgumentError('invalid opts')
|
||||
}
|
||||
|
||||
const { signal, method, opaque, body, onInfo, responseHeaders, highWaterMark } = opts
|
||||
|
||||
try {
|
||||
if (typeof callback !== 'function') {
|
||||
throw new InvalidArgumentError('invalid callback')
|
||||
}
|
||||
|
||||
if (highWaterMark != null && (!Number.isFinite(highWaterMark) || highWaterMark < 0)) {
|
||||
throw new InvalidArgumentError('invalid highWaterMark')
|
||||
}
|
||||
|
||||
if (signal && typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') {
|
||||
throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget')
|
||||
}
|
||||
|
||||
if (method === 'CONNECT') {
|
||||
throw new InvalidArgumentError('invalid method')
|
||||
}
|
||||
|
||||
if (onInfo && typeof onInfo !== 'function') {
|
||||
throw new InvalidArgumentError('invalid onInfo callback')
|
||||
}
|
||||
|
||||
super('UNDICI_REQUEST')
|
||||
} catch (err) {
|
||||
if (util.isStream(body)) {
|
||||
util.destroy(body.on('error', noop), err)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
this.method = method
|
||||
this.responseHeaders = responseHeaders || null
|
||||
this.opaque = opaque || null
|
||||
this.callback = callback
|
||||
this.res = null
|
||||
this.abort = null
|
||||
this.body = body
|
||||
this.trailers = {}
|
||||
this.context = null
|
||||
this.controller = null
|
||||
this.onInfo = onInfo || null
|
||||
this.highWaterMark = highWaterMark
|
||||
this.reason = null
|
||||
this.removeAbortListener = null
|
||||
|
||||
if (signal?.aborted) {
|
||||
this.reason = signal.reason ?? new RequestAbortedError()
|
||||
} else if (signal) {
|
||||
this.removeAbortListener = util.addAbortListener(signal, () => {
|
||||
this.reason = signal.reason ?? new RequestAbortedError()
|
||||
if (this.res) {
|
||||
// Null the reference before destroying, mirroring onResponseError, so
|
||||
// that chunks flushed after the abort (e.g. an async decompressor
|
||||
// flush) are dropped by the `!this.res` guard in onResponseData
|
||||
// instead of being pushed into the torn-down stream.
|
||||
const res = this.res
|
||||
this.res = null
|
||||
util.destroy(res.on('error', noop), this.reason)
|
||||
} else if (this.abort) {
|
||||
this.abort(this.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onRequestStart (controller, context) {
|
||||
if (this.reason) {
|
||||
controller.abort(this.reason)
|
||||
return
|
||||
}
|
||||
|
||||
assert(this.callback)
|
||||
|
||||
this.controller = controller
|
||||
this.abort = (reason) => controller.abort(reason)
|
||||
this.context = context
|
||||
}
|
||||
|
||||
onResponseStart (controller, statusCode, headers, statusText) {
|
||||
const { callback, opaque, context, responseHeaders, highWaterMark } = this
|
||||
|
||||
const rawHeaders = controller?.rawHeaders
|
||||
const responseHeaderData = responseHeaders === 'raw'
|
||||
? util.parseRawHeaders(rawHeaders)
|
||||
: headers
|
||||
|
||||
if (statusCode < 200) {
|
||||
if (this.onInfo) {
|
||||
this.onInfo({ statusCode, headers: responseHeaderData })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const parsedHeaders = headers
|
||||
const contentType = parsedHeaders?.['content-type']
|
||||
const contentLength = parsedHeaders?.['content-length']
|
||||
const res = new Readable({
|
||||
resume: () => controller.resume(),
|
||||
abort: (reason) => controller.abort(reason),
|
||||
contentType,
|
||||
contentLength: this.method !== 'HEAD' && contentLength
|
||||
? Number(contentLength)
|
||||
: null,
|
||||
highWaterMark
|
||||
})
|
||||
|
||||
if (this.removeAbortListener) {
|
||||
res.on('close', this.removeAbortListener)
|
||||
this.removeAbortListener = null
|
||||
}
|
||||
|
||||
this.callback = null
|
||||
this.res = res
|
||||
if (callback !== null) {
|
||||
try {
|
||||
this.runInAsyncScope(callback, null, null, {
|
||||
statusCode,
|
||||
statusText,
|
||||
headers: responseHeaderData,
|
||||
trailers: this.trailers,
|
||||
opaque,
|
||||
body: res,
|
||||
context
|
||||
})
|
||||
} catch (err) {
|
||||
// If the callback throws synchronously, we need to handle it
|
||||
// Remove reference to res to allow res being garbage collected
|
||||
this.res = null
|
||||
|
||||
// Destroy the response stream
|
||||
util.destroy(res.on('error', noop), err)
|
||||
|
||||
// Use queueMicrotask to re-throw the error so it reaches uncaughtException
|
||||
queueMicrotask(() => {
|
||||
throw err
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onResponseData (controller, chunk) {
|
||||
if (!this.res) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.res.push(chunk) === false) {
|
||||
controller.pause()
|
||||
}
|
||||
}
|
||||
|
||||
onResponseEnd (_controller, trailers) {
|
||||
if (trailers && typeof trailers === 'object') {
|
||||
for (const key of Object.keys(trailers)) {
|
||||
if (key === '__proto__') {
|
||||
Object.defineProperty(this.trailers, key, {
|
||||
value: trailers[key],
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true
|
||||
})
|
||||
} else {
|
||||
this.trailers[key] = trailers[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
this.res?.push(null)
|
||||
}
|
||||
|
||||
onResponseError (_controller, err) {
|
||||
const { res, callback, body, opaque } = this
|
||||
|
||||
if (callback) {
|
||||
// TODO: Does this need queueMicrotask?
|
||||
this.callback = null
|
||||
queueMicrotask(() => {
|
||||
this.runInAsyncScope(callback, null, err, { opaque })
|
||||
})
|
||||
}
|
||||
|
||||
if (res) {
|
||||
this.res = null
|
||||
// Ensure all queued handlers are invoked before destroying res.
|
||||
queueMicrotask(() => {
|
||||
util.destroy(res.on('error', noop), err)
|
||||
})
|
||||
}
|
||||
|
||||
if (body) {
|
||||
this.body = null
|
||||
|
||||
if (util.isStream(body)) {
|
||||
body.on('error', noop)
|
||||
util.destroy(body, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.removeAbortListener) {
|
||||
this.removeAbortListener()
|
||||
this.removeAbortListener = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function request (opts, callback) {
|
||||
if (callback === undefined) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.call(this, opts, (err, data) => {
|
||||
return err ? reject(err) : resolve(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const handler = new RequestHandler(opts, callback)
|
||||
|
||||
this.dispatch(opts, handler)
|
||||
} catch (err) {
|
||||
if (typeof callback !== 'function') {
|
||||
throw err
|
||||
}
|
||||
const opaque = opts?.opaque
|
||||
queueMicrotask(() => callback(err, { opaque }))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = request
|
||||
module.exports.RequestHandler = RequestHandler
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert')
|
||||
const { AsyncResource } = require('node:async_hooks')
|
||||
const { InvalidArgumentError, InvalidReturnValueError } = require('../core/errors')
|
||||
const util = require('../core/util')
|
||||
const { addSignal, removeSignal } = require('./abort-signal')
|
||||
|
||||
function noop () {}
|
||||
|
||||
function getWritableError (stream) {
|
||||
return stream.errored ?? stream.writableErrored ?? stream._writableState?.errored
|
||||
}
|
||||
|
||||
function createPrematureCloseError () {
|
||||
const err = new Error('Premature close')
|
||||
err.code = 'ERR_STREAM_PREMATURE_CLOSE'
|
||||
return err
|
||||
}
|
||||
|
||||
function trackWritableLifecycle (stream, callback) {
|
||||
let done = false
|
||||
|
||||
const cleanup = () => {
|
||||
stream.removeListener('close', onClose)
|
||||
stream.removeListener('error', onError)
|
||||
stream.removeListener('finish', onFinish)
|
||||
}
|
||||
|
||||
const finish = (err, fromErrorEvent = false) => {
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
|
||||
done = true
|
||||
cleanup()
|
||||
callback(err, fromErrorEvent)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
const err = getWritableError(stream)
|
||||
finish(err ?? (!stream.writableFinished ? createPrematureCloseError() : undefined))
|
||||
}
|
||||
|
||||
const onError = (err) => finish(err, true)
|
||||
const onFinish = () => finish()
|
||||
|
||||
stream.on('close', onClose)
|
||||
stream.on('error', onError)
|
||||
stream.on('finish', onFinish)
|
||||
|
||||
if (stream.closed) {
|
||||
process.nextTick(onClose)
|
||||
} else if (stream.writableFinished) {
|
||||
process.nextTick(onFinish)
|
||||
}
|
||||
}
|
||||
|
||||
class StreamHandler extends AsyncResource {
|
||||
constructor (opts, factory, callback) {
|
||||
if (!opts || typeof opts !== 'object') {
|
||||
throw new InvalidArgumentError('invalid opts')
|
||||
}
|
||||
|
||||
const { signal, method, opaque, body, onInfo, responseHeaders } = opts
|
||||
|
||||
try {
|
||||
if (typeof callback !== 'function') {
|
||||
throw new InvalidArgumentError('invalid callback')
|
||||
}
|
||||
|
||||
if (typeof factory !== 'function') {
|
||||
throw new InvalidArgumentError('invalid factory')
|
||||
}
|
||||
|
||||
if (signal && typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') {
|
||||
throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget')
|
||||
}
|
||||
|
||||
if (method === 'CONNECT') {
|
||||
throw new InvalidArgumentError('invalid method')
|
||||
}
|
||||
|
||||
if (onInfo && typeof onInfo !== 'function') {
|
||||
throw new InvalidArgumentError('invalid onInfo callback')
|
||||
}
|
||||
|
||||
super('UNDICI_STREAM')
|
||||
} catch (err) {
|
||||
if (util.isStream(body)) {
|
||||
util.destroy(body.on('error', noop), err)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
this.responseHeaders = responseHeaders || null
|
||||
this.opaque = opaque || null
|
||||
this.factory = factory
|
||||
this.callback = callback
|
||||
this.res = null
|
||||
this.abort = null
|
||||
this.context = null
|
||||
this.controller = null
|
||||
this.trailers = null
|
||||
this.body = body
|
||||
this.onInfo = onInfo || null
|
||||
|
||||
if (util.isStream(body)) {
|
||||
body.on('error', (err) => {
|
||||
this.onResponseError(this.controller, err)
|
||||
})
|
||||
}
|
||||
|
||||
addSignal(this, signal)
|
||||
}
|
||||
|
||||
onRequestStart (controller, context) {
|
||||
if (this.reason) {
|
||||
controller.abort(this.reason)
|
||||
return
|
||||
}
|
||||
|
||||
assert(this.callback)
|
||||
|
||||
this.controller = controller
|
||||
this.abort = (reason) => controller.abort(reason)
|
||||
this.context = context
|
||||
}
|
||||
|
||||
onResponseStart (controller, statusCode, headers, _statusMessage) {
|
||||
const { factory, opaque, context, responseHeaders } = this
|
||||
|
||||
const rawHeaders = controller?.rawHeaders
|
||||
const responseHeaderData = responseHeaders === 'raw'
|
||||
? util.parseRawHeaders(rawHeaders)
|
||||
: headers
|
||||
|
||||
if (statusCode < 200) {
|
||||
if (this.onInfo) {
|
||||
this.onInfo({ statusCode, headers: responseHeaderData })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.factory = null
|
||||
|
||||
if (factory === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const res = this.runInAsyncScope(factory, null, {
|
||||
statusCode,
|
||||
headers: responseHeaderData,
|
||||
opaque,
|
||||
context
|
||||
})
|
||||
|
||||
if (
|
||||
!res ||
|
||||
typeof res.write !== 'function' ||
|
||||
typeof res.end !== 'function' ||
|
||||
typeof res.on !== 'function'
|
||||
) {
|
||||
throw new InvalidReturnValueError('expected Writable')
|
||||
}
|
||||
|
||||
trackWritableLifecycle(res, (err, fromErrorEvent) => {
|
||||
const { callback, res, opaque, trailers, abort } = this
|
||||
|
||||
this.res = null
|
||||
if (err || !res?.readable) {
|
||||
util.destroy(res, fromErrorEvent ? undefined : err)
|
||||
}
|
||||
|
||||
this.callback = null
|
||||
this.runInAsyncScope(callback, null, err || null, { opaque, trailers })
|
||||
|
||||
if (err) {
|
||||
abort(err)
|
||||
}
|
||||
})
|
||||
|
||||
res.on('drain', () => controller.resume())
|
||||
|
||||
this.res = res
|
||||
|
||||
const needDrain = res.writableNeedDrain !== undefined
|
||||
? res.writableNeedDrain
|
||||
: res._writableState?.needDrain
|
||||
|
||||
if (needDrain === true) {
|
||||
controller.pause()
|
||||
}
|
||||
}
|
||||
|
||||
onResponseData (controller, chunk) {
|
||||
const { res } = this
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
if (res.write(chunk) === false) {
|
||||
controller.pause()
|
||||
}
|
||||
}
|
||||
|
||||
onResponseEnd (_controller, trailers) {
|
||||
const { res } = this
|
||||
|
||||
removeSignal(this)
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
if (trailers && typeof trailers === 'object') {
|
||||
this.trailers = trailers
|
||||
}
|
||||
|
||||
res.end()
|
||||
}
|
||||
|
||||
onResponseError (_controller, err) {
|
||||
const { res, callback, opaque, body } = this
|
||||
|
||||
removeSignal(this)
|
||||
|
||||
this.factory = null
|
||||
|
||||
if (res) {
|
||||
this.res = null
|
||||
util.destroy(res, err)
|
||||
} else if (callback) {
|
||||
this.callback = null
|
||||
queueMicrotask(() => {
|
||||
this.runInAsyncScope(callback, null, err, { opaque })
|
||||
})
|
||||
}
|
||||
|
||||
if (body) {
|
||||
this.body = null
|
||||
util.destroy(body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stream (opts, factory, callback) {
|
||||
if (callback === undefined) {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.call(this, opts, factory, (err, data) => {
|
||||
return err ? reject(err) : resolve(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const handler = new StreamHandler(opts, factory, callback)
|
||||
|
||||
this.dispatch(opts, handler)
|
||||
} catch (err) {
|
||||
if (typeof callback !== 'function') {
|
||||
throw err
|
||||
}
|
||||
const opaque = opts?.opaque
|
||||
queueMicrotask(() => callback(err, { opaque }))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = stream
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
'use strict'
|
||||
|
||||
const { InvalidArgumentError, SocketError } = require('../core/errors')
|
||||
const { AsyncResource } = require('node:async_hooks')
|
||||
const assert = require('node:assert')
|
||||
const util = require('../core/util')
|
||||
const { kHTTP2Stream } = require('../core/symbols')
|
||||
const { addSignal, removeSignal } = require('./abort-signal')
|
||||
|
||||
class UpgradeHandler extends AsyncResource {
|
||||
constructor (opts, callback) {
|
||||
if (!opts || typeof opts !== 'object') {
|
||||
throw new InvalidArgumentError('invalid opts')
|
||||
}
|
||||
|
||||
if (typeof callback !== 'function') {
|
||||
throw new InvalidArgumentError('invalid callback')
|
||||
}
|
||||
|
||||
const { signal, opaque, responseHeaders } = opts
|
||||
|
||||
if (signal && typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') {
|
||||
throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget')
|
||||
}
|
||||
|
||||
super('UNDICI_UPGRADE')
|
||||
|
||||
this.responseHeaders = responseHeaders || null
|
||||
this.opaque = opaque || null
|
||||
this.callback = callback
|
||||
this.abort = null
|
||||
this.context = null
|
||||
|
||||
addSignal(this, signal)
|
||||
}
|
||||
|
||||
onRequestStart (controller, context) {
|
||||
if (this.reason) {
|
||||
controller.abort(this.reason)
|
||||
return
|
||||
}
|
||||
|
||||
assert(this.callback)
|
||||
|
||||
this.abort = (reason) => controller.abort(reason)
|
||||
this.context = context
|
||||
}
|
||||
|
||||
onResponseStart () {
|
||||
throw new SocketError('bad upgrade', null)
|
||||
}
|
||||
|
||||
onRequestUpgrade (controller, statusCode, headers, socket) {
|
||||
const expectedStatusCode = socket[kHTTP2Stream] === true ? 200 : 101
|
||||
|
||||
if (statusCode !== expectedStatusCode) {
|
||||
const socketInfo = socket[kHTTP2Stream] === true ? null : util.getSocketInfo(socket)
|
||||
controller.abort(new SocketError('bad upgrade', socketInfo))
|
||||
return
|
||||
}
|
||||
|
||||
const { callback, opaque, context } = this
|
||||
|
||||
removeSignal(this)
|
||||
|
||||
this.callback = null
|
||||
|
||||
const rawHeaders = controller?.rawHeaders
|
||||
const responseHeaders = this.responseHeaders === 'raw'
|
||||
? util.parseRawHeaders(rawHeaders)
|
||||
: headers
|
||||
|
||||
this.runInAsyncScope(callback, null, null, {
|
||||
headers: responseHeaders,
|
||||
socket,
|
||||
opaque,
|
||||
context
|
||||
})
|
||||
}
|
||||
|
||||
onResponseError (_controller, err) {
|
||||
const { callback, opaque } = this
|
||||
|
||||
removeSignal(this)
|
||||
|
||||
if (callback) {
|
||||
this.callback = null
|
||||
queueMicrotask(() => {
|
||||
this.runInAsyncScope(callback, null, err, { opaque })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function upgrade (opts, callback) {
|
||||
if (callback === undefined) {
|
||||
return new Promise((resolve, reject) => {
|
||||
upgrade.call(this, opts, (err, data) => {
|
||||
return err ? reject(err) : resolve(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const upgradeHandler = new UpgradeHandler(opts, callback)
|
||||
const upgradeOpts = {
|
||||
...opts,
|
||||
method: opts.method || 'GET',
|
||||
upgrade: opts.protocol || 'Websocket'
|
||||
}
|
||||
this.dispatch(upgradeOpts, upgradeHandler)
|
||||
} catch (err) {
|
||||
if (typeof callback !== 'function') {
|
||||
throw err
|
||||
}
|
||||
const opaque = opts?.opaque
|
||||
queueMicrotask(() => callback(err, { opaque }))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = upgrade
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
module.exports.request = require('./api-request')
|
||||
module.exports.stream = require('./api-stream')
|
||||
module.exports.pipeline = require('./api-pipeline')
|
||||
module.exports.upgrade = require('./api-upgrade')
|
||||
module.exports.connect = require('./api-connect')
|
||||
+615
@@ -0,0 +1,615 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert')
|
||||
const { addAbortListener } = require('node:events')
|
||||
const { Readable } = require('node:stream')
|
||||
const { RequestAbortedError, NotSupportedError, InvalidArgumentError, AbortError } = require('../core/errors')
|
||||
const util = require('../core/util')
|
||||
|
||||
const kConsume = Symbol('kConsume')
|
||||
const kReading = Symbol('kReading')
|
||||
const kBody = Symbol('kBody')
|
||||
const kAbort = Symbol('kAbort')
|
||||
const kContentType = Symbol('kContentType')
|
||||
const kContentLength = Symbol('kContentLength')
|
||||
const kUsed = Symbol('kUsed')
|
||||
const kBytesRead = Symbol('kBytesRead')
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
/**
|
||||
* @class
|
||||
* @extends {Readable}
|
||||
* @see https://fetch.spec.whatwg.org/#body
|
||||
*/
|
||||
class BodyReadable extends Readable {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {(this: Readable, size: number) => void} opts.resume
|
||||
* @param {() => (void | null)} opts.abort
|
||||
* @param {string} [opts.contentType = '']
|
||||
* @param {number} [opts.contentLength]
|
||||
* @param {number} [opts.highWaterMark = 64 * 1024]
|
||||
*/
|
||||
constructor ({
|
||||
resume,
|
||||
abort,
|
||||
contentType = '',
|
||||
contentLength,
|
||||
highWaterMark = 64 * 1024 // Same as nodejs fs streams.
|
||||
}) {
|
||||
super({
|
||||
autoDestroy: true,
|
||||
read: resume,
|
||||
highWaterMark
|
||||
})
|
||||
|
||||
this._readableState.dataEmitted = false
|
||||
|
||||
this[kAbort] = abort
|
||||
|
||||
/** @type {Consume | null} */
|
||||
this[kConsume] = null
|
||||
|
||||
/** @type {number} */
|
||||
this[kBytesRead] = 0
|
||||
|
||||
/** @type {ReadableStream|null} */
|
||||
this[kBody] = null
|
||||
|
||||
/** @type {boolean} */
|
||||
this[kUsed] = false
|
||||
|
||||
/** @type {string} */
|
||||
this[kContentType] = contentType
|
||||
|
||||
/** @type {number|null} */
|
||||
this[kContentLength] = Number.isFinite(contentLength) ? contentLength : null
|
||||
|
||||
/**
|
||||
* Is stream being consumed through Readable API?
|
||||
* This is an optimization so that we avoid checking
|
||||
* for 'data' and 'readable' listeners in the hot path
|
||||
* inside push().
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
this[kReading] = false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Error|null} err
|
||||
* @param {(error:(Error|null)) => void} callback
|
||||
* @returns {void}
|
||||
*/
|
||||
_destroy (err, callback) {
|
||||
if (!err && !this._readableState.endEmitted) {
|
||||
err = new RequestAbortedError()
|
||||
}
|
||||
|
||||
if (err) {
|
||||
this[kAbort]()
|
||||
}
|
||||
|
||||
// Workaround for Node "bug". If the stream is destroyed in same
|
||||
// tick as it is created, then a user who is waiting for a
|
||||
// promise (i.e micro tick) for installing an 'error' listener will
|
||||
// never get a chance and will always encounter an unhandled exception.
|
||||
if (!this[kUsed]) {
|
||||
setImmediate(callback, err)
|
||||
} else {
|
||||
callback(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|symbol} event
|
||||
* @param {(...args: any[]) => void} listener
|
||||
* @returns {this}
|
||||
*/
|
||||
on (event, listener) {
|
||||
if (event === 'data' || event === 'readable') {
|
||||
this[kReading] = true
|
||||
this[kUsed] = true
|
||||
}
|
||||
return super.on(event, listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|symbol} event
|
||||
* @param {(...args: any[]) => void} listener
|
||||
* @returns {this}
|
||||
*/
|
||||
addListener (event, listener) {
|
||||
return this.on(event, listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|symbol} event
|
||||
* @param {(...args: any[]) => void} listener
|
||||
* @returns {this}
|
||||
*/
|
||||
off (event, listener) {
|
||||
const ret = super.off(event, listener)
|
||||
if (event === 'data' || event === 'readable') {
|
||||
this[kReading] = (
|
||||
this.listenerCount('data') > 0 ||
|
||||
this.listenerCount('readable') > 0
|
||||
)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|symbol} event
|
||||
* @param {(...args: any[]) => void} listener
|
||||
* @returns {this}
|
||||
*/
|
||||
removeListener (event, listener) {
|
||||
return this.off(event, listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer|null} chunk
|
||||
* @returns {boolean}
|
||||
*/
|
||||
push (chunk) {
|
||||
if (chunk) {
|
||||
this[kBytesRead] += chunk.length
|
||||
if (this[kConsume]) {
|
||||
consumePush(this[kConsume], chunk)
|
||||
return this[kReading] ? super.push(chunk) : true
|
||||
}
|
||||
}
|
||||
|
||||
return super.push(chunk)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes and returns the body as a string.
|
||||
*
|
||||
* @see https://fetch.spec.whatwg.org/#dom-body-text
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
text () {
|
||||
return consume(this, 'text')
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes and returns the body as a JavaScript Object.
|
||||
*
|
||||
* @see https://fetch.spec.whatwg.org/#dom-body-json
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
json () {
|
||||
return consume(this, 'json')
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes and returns the body as a Blob
|
||||
*
|
||||
* @see https://fetch.spec.whatwg.org/#dom-body-blob
|
||||
* @returns {Promise<Blob>}
|
||||
*/
|
||||
blob () {
|
||||
return consume(this, 'blob')
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes and returns the body as an Uint8Array.
|
||||
*
|
||||
* @see https://fetch.spec.whatwg.org/#dom-body-bytes
|
||||
* @returns {Promise<Uint8Array>}
|
||||
*/
|
||||
bytes () {
|
||||
return consume(this, 'bytes')
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes and returns the body as an ArrayBuffer.
|
||||
*
|
||||
* @see https://fetch.spec.whatwg.org/#dom-body-arraybuffer
|
||||
* @returns {Promise<ArrayBuffer>}
|
||||
*/
|
||||
arrayBuffer () {
|
||||
return consume(this, 'arrayBuffer')
|
||||
}
|
||||
|
||||
/**
|
||||
* Not implemented
|
||||
*
|
||||
* @see https://fetch.spec.whatwg.org/#dom-body-formdata
|
||||
* @throws {NotSupportedError}
|
||||
*/
|
||||
async formData () {
|
||||
// TODO: Implement.
|
||||
throw new NotSupportedError()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the body is not null and the body has been consumed.
|
||||
* Otherwise, returns false.
|
||||
*
|
||||
* @see https://fetch.spec.whatwg.org/#dom-body-bodyused
|
||||
* @readonly
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get bodyUsed () {
|
||||
return util.isDisturbed(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://fetch.spec.whatwg.org/#dom-body-body
|
||||
* @readonly
|
||||
* @returns {ReadableStream}
|
||||
*/
|
||||
get body () {
|
||||
if (!this[kBody]) {
|
||||
this[kBody] = ReadableStream.from(this)
|
||||
if (this[kConsume]) {
|
||||
// TODO: Is this the best way to force a lock?
|
||||
this[kBody].getReader() // Ensure stream is locked.
|
||||
assert(this[kBody].locked)
|
||||
}
|
||||
}
|
||||
return this[kBody]
|
||||
}
|
||||
|
||||
/**
|
||||
* Dumps the response body by reading `limit` number of bytes.
|
||||
* @param {object} opts
|
||||
* @param {number} [opts.limit = 131072] Number of bytes to read.
|
||||
* @param {AbortSignal} [opts.signal] An AbortSignal to cancel the dump.
|
||||
* @returns {Promise<null>}
|
||||
*/
|
||||
dump (opts) {
|
||||
const signal = opts?.signal
|
||||
|
||||
if (signal != null && (typeof signal !== 'object' || !('aborted' in signal))) {
|
||||
return Promise.reject(new InvalidArgumentError('signal must be an AbortSignal'))
|
||||
}
|
||||
|
||||
const limit = opts?.limit && Number.isFinite(opts.limit)
|
||||
? opts.limit
|
||||
: 128 * 1024
|
||||
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(signal.reason ?? new AbortError())
|
||||
}
|
||||
|
||||
if (this._readableState.closeEmitted) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (
|
||||
(this[kContentLength] && (this[kContentLength] > limit)) ||
|
||||
this[kBytesRead] > limit
|
||||
) {
|
||||
this.destroy(new AbortError())
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
const onAbort = () => {
|
||||
this.destroy(signal.reason ?? new AbortError())
|
||||
}
|
||||
const abortListener = addAbortListener(signal, onAbort)
|
||||
this
|
||||
.on('close', function () {
|
||||
abortListener[Symbol.dispose]()
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason ?? new AbortError())
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.on('close', resolve)
|
||||
}
|
||||
|
||||
this
|
||||
.on('error', noop)
|
||||
.on('data', () => {
|
||||
if (this[kBytesRead] > limit) {
|
||||
this.destroy()
|
||||
}
|
||||
})
|
||||
.resume()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BufferEncoding} encoding
|
||||
* @returns {this}
|
||||
*/
|
||||
setEncoding (encoding) {
|
||||
if (Buffer.isEncoding(encoding)) {
|
||||
// Delegate to Node.js Readable.setEncoding() which initializes a
|
||||
// StringDecoder and re-encodes already-buffered chunks. This properly
|
||||
// handles multi-byte sequences split at chunk boundaries for the
|
||||
// for-await / on('data') paths. Without this, Node.js uses
|
||||
// buf.toString(encoding) on each chunk, producing U+FFFD for split chars.
|
||||
//
|
||||
// The consume path (body.text(), body.json(), ...) copes with the
|
||||
// decoded strings this leaves in state.buffer, see consumeStart().
|
||||
super.setEncoding(encoding)
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://streams.spec.whatwg.org/#readablestream-locked
|
||||
* @param {BodyReadable} bodyReadable
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isLocked (bodyReadable) {
|
||||
// Consume is an implicit lock.
|
||||
return bodyReadable[kBody]?.locked === true || bodyReadable[kConsume] !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://fetch.spec.whatwg.org/#body-unusable
|
||||
* @param {BodyReadable} bodyReadable
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isUnusable (bodyReadable) {
|
||||
return util.isDisturbed(bodyReadable) || isLocked(bodyReadable)
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {'text' | 'json' | 'blob' | 'bytes' | 'arrayBuffer'} ConsumeType
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template {ConsumeType} T
|
||||
* @typedef {T extends 'text' ? string :
|
||||
* T extends 'json' ? unknown :
|
||||
* T extends 'blob' ? Blob :
|
||||
* T extends 'arrayBuffer' ? ArrayBuffer :
|
||||
* T extends 'bytes' ? Uint8Array :
|
||||
* never
|
||||
* } ConsumeReturnType
|
||||
*/
|
||||
/**
|
||||
* @typedef {object} Consume
|
||||
* @property {ConsumeType} type
|
||||
* @property {BodyReadable} stream
|
||||
* @property {((value?: any) => void)} resolve
|
||||
* @property {((err: Error) => void)} reject
|
||||
* @property {number} length
|
||||
* @property {Buffer[]} body
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template {ConsumeType} T
|
||||
* @param {BodyReadable} stream
|
||||
* @param {T} type
|
||||
* @returns {Promise<ConsumeReturnType<T>>}
|
||||
*/
|
||||
function consume (stream, type) {
|
||||
assert(!stream[kConsume])
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isUnusable(stream)) {
|
||||
const rState = stream._readableState
|
||||
if (rState.destroyed && rState.closeEmitted === false) {
|
||||
stream
|
||||
.on('error', reject)
|
||||
.on('close', () => {
|
||||
reject(new TypeError('unusable'))
|
||||
})
|
||||
} else {
|
||||
reject(rState.errored ?? new TypeError('unusable'))
|
||||
}
|
||||
} else {
|
||||
queueMicrotask(() => {
|
||||
stream[kConsume] = {
|
||||
type,
|
||||
stream,
|
||||
resolve,
|
||||
reject,
|
||||
length: 0,
|
||||
body: []
|
||||
}
|
||||
|
||||
stream
|
||||
.on('error', function (err) {
|
||||
consumeFinish(this[kConsume], err)
|
||||
})
|
||||
.on('close', function () {
|
||||
if (this[kConsume].body !== null) {
|
||||
consumeFinish(this[kConsume], new RequestAbortedError())
|
||||
}
|
||||
})
|
||||
|
||||
consumeStart(stream[kConsume])
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Consume} consume
|
||||
* @returns {void}
|
||||
*/
|
||||
function consumeStart (consume) {
|
||||
if (consume.body === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const { _readableState: state } = consume.stream
|
||||
|
||||
if (state.bufferIndex) {
|
||||
const start = state.bufferIndex
|
||||
const end = state.buffer.length
|
||||
for (let n = start; n < end; n++) {
|
||||
consumePush(consume, state.buffer[n])
|
||||
}
|
||||
} else {
|
||||
for (const chunk of state.buffer) {
|
||||
consumePush(consume, chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// If setEncoding() was called, state.buffer holds decoded strings, which
|
||||
// consumePush() turns back into bytes. The trailing bytes of a multi-byte
|
||||
// sequence split across a chunk boundary are not part of any of those
|
||||
// strings, they are held inside the decoder until the rest arrives, so
|
||||
// take them from there.
|
||||
const decoder = state.decoder
|
||||
if (decoder != null && decoder.lastNeed > 0) {
|
||||
consumePush(consume, Buffer.from(decoder.lastChar.subarray(0, decoder.lastTotal - decoder.lastNeed)))
|
||||
}
|
||||
|
||||
if (state.endEmitted) {
|
||||
// No `this` to read the consume off here: consumeStart is a free function, called from
|
||||
// the queueMicrotask above. The callback below does have one, because the emitter passes
|
||||
// the stream as its receiver. Returning matters too - consumeEnd() clears consume.stream,
|
||||
// which the resume() below would then dereference.
|
||||
consumeEnd(consume, state.encoding)
|
||||
return
|
||||
}
|
||||
|
||||
consume.stream.on('end', function () {
|
||||
consumeEnd(this[kConsume], this._readableState.encoding)
|
||||
})
|
||||
|
||||
consume.stream.resume()
|
||||
|
||||
while (consume.stream.read() != null) {
|
||||
// Loop
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer[]} chunks
|
||||
* @param {number} length
|
||||
* @param {BufferEncoding} [encoding='utf8']
|
||||
* @returns {string}
|
||||
*/
|
||||
function chunksDecode (chunks, length, encoding) {
|
||||
if (chunks.length === 0 || length === 0) {
|
||||
return ''
|
||||
}
|
||||
const buffer = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, length)
|
||||
const bufferLength = buffer.length
|
||||
|
||||
// Skip BOM.
|
||||
const start =
|
||||
bufferLength > 2 &&
|
||||
buffer[0] === 0xef &&
|
||||
buffer[1] === 0xbb &&
|
||||
buffer[2] === 0xbf
|
||||
? 3
|
||||
: 0
|
||||
if (!encoding || encoding === 'utf8' || encoding === 'utf-8') {
|
||||
return buffer.utf8Slice(start, bufferLength)
|
||||
} else {
|
||||
return buffer.subarray(start, bufferLength).toString(encoding)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer[]} chunks
|
||||
* @param {number} length
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
function chunksConcat (chunks, length) {
|
||||
if (chunks.length === 0 || length === 0) {
|
||||
return new Uint8Array(0)
|
||||
}
|
||||
if (chunks.length === 1) {
|
||||
// fast-path
|
||||
return new Uint8Array(chunks[0])
|
||||
}
|
||||
const buffer = new Uint8Array(Buffer.allocUnsafeSlow(length).buffer)
|
||||
|
||||
let offset = 0
|
||||
for (let i = 0; i < chunks.length; ++i) {
|
||||
const chunk = chunks[i]
|
||||
buffer.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Consume} consume
|
||||
* @param {BufferEncoding} encoding
|
||||
* @returns {void}
|
||||
*/
|
||||
function consumeEnd (consume, encoding) {
|
||||
const { type, body, resolve, stream, length } = consume
|
||||
|
||||
try {
|
||||
if (type === 'text') {
|
||||
resolve(chunksDecode(body, length, encoding))
|
||||
} else if (type === 'json') {
|
||||
resolve(JSON.parse(chunksDecode(body, length, encoding)))
|
||||
} else if (type === 'arrayBuffer') {
|
||||
resolve(chunksConcat(body, length).buffer)
|
||||
} else if (type === 'blob') {
|
||||
resolve(new Blob(body, { type: stream[kContentType] }))
|
||||
} else if (type === 'bytes') {
|
||||
resolve(chunksConcat(body, length))
|
||||
}
|
||||
|
||||
consumeFinish(consume)
|
||||
} catch (err) {
|
||||
stream.destroy(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Consume} consume
|
||||
* @param {Buffer|string} chunk
|
||||
* @returns {void}
|
||||
*/
|
||||
function consumePush (consume, chunk) {
|
||||
if (consume.body === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof chunk === 'string') {
|
||||
// Buffered before the consume started, while an encoding was set.
|
||||
// consume.length has to stay a byte count and chunksDecode()/chunksConcat()
|
||||
// only work on bytes, so re-encode. A string's own length is in UTF-16 code
|
||||
// units and Uint8Array.prototype.set() ignores a string argument entirely.
|
||||
chunk = Buffer.from(chunk, consume.stream._readableState.encoding)
|
||||
}
|
||||
|
||||
consume.length += chunk.length
|
||||
consume.body.push(chunk)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Consume} consume
|
||||
* @param {Error} [err]
|
||||
* @returns {void}
|
||||
*/
|
||||
function consumeFinish (consume, err) {
|
||||
if (consume.body === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (err) {
|
||||
consume.reject(err)
|
||||
} else {
|
||||
consume.resolve()
|
||||
}
|
||||
|
||||
// Reset the consume object to allow for garbage collection.
|
||||
consume.type = null
|
||||
consume.stream = null
|
||||
consume.resolve = null
|
||||
consume.reject = null
|
||||
consume.length = 0
|
||||
consume.body = null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Readable: BodyReadable,
|
||||
chunksDecode
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
'use strict'
|
||||
|
||||
const { Writable } = require('node:stream')
|
||||
const { EventEmitter } = require('node:events')
|
||||
const { assertCacheKey, assertCacheValue } = require('../util/cache.js')
|
||||
|
||||
/**
|
||||
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheKey} CacheKey
|
||||
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheValue} CacheValue
|
||||
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
|
||||
* @typedef {import('../../types/cache-interceptor.d.ts').default.GetResult} GetResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* @implements {CacheStore}
|
||||
* @extends {EventEmitter}
|
||||
*/
|
||||
class MemoryCacheStore extends EventEmitter {
|
||||
#maxCount = 1024
|
||||
#maxSize = 104857600 // 100MB
|
||||
#maxEntrySize = 5242880 // 5MB
|
||||
|
||||
#size = 0
|
||||
#count = 0
|
||||
#entries = new Map()
|
||||
#hasEmittedMaxSizeEvent = false
|
||||
|
||||
/**
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.MemoryCacheStoreOpts | undefined} [opts]
|
||||
*/
|
||||
constructor (opts) {
|
||||
super()
|
||||
if (opts) {
|
||||
if (typeof opts !== 'object') {
|
||||
throw new TypeError('MemoryCacheStore options must be an object')
|
||||
}
|
||||
|
||||
if (opts.maxCount !== undefined) {
|
||||
if (
|
||||
typeof opts.maxCount !== 'number' ||
|
||||
!Number.isInteger(opts.maxCount) ||
|
||||
opts.maxCount < 0
|
||||
) {
|
||||
throw new TypeError('MemoryCacheStore options.maxCount must be a non-negative integer')
|
||||
}
|
||||
this.#maxCount = opts.maxCount
|
||||
}
|
||||
|
||||
if (opts.maxSize !== undefined) {
|
||||
if (
|
||||
typeof opts.maxSize !== 'number' ||
|
||||
!Number.isInteger(opts.maxSize) ||
|
||||
opts.maxSize < 0
|
||||
) {
|
||||
throw new TypeError('MemoryCacheStore options.maxSize must be a non-negative integer')
|
||||
}
|
||||
this.#maxSize = opts.maxSize
|
||||
}
|
||||
|
||||
if (opts.maxEntrySize !== undefined) {
|
||||
if (
|
||||
typeof opts.maxEntrySize !== 'number' ||
|
||||
!Number.isInteger(opts.maxEntrySize) ||
|
||||
opts.maxEntrySize < 0
|
||||
) {
|
||||
throw new TypeError('MemoryCacheStore options.maxEntrySize must be a non-negative integer')
|
||||
}
|
||||
this.#maxEntrySize = opts.maxEntrySize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current size of the cache in bytes
|
||||
* @returns {number} The current size of the cache in bytes
|
||||
*/
|
||||
get size () {
|
||||
return this.#size
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the cache is full (either max size or max count reached)
|
||||
* @returns {boolean} True if the cache is full, false otherwise
|
||||
*/
|
||||
isFull () {
|
||||
return this.#size >= this.#maxSize || this.#count >= this.#maxCount
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
|
||||
* @returns {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined}
|
||||
*/
|
||||
get (key) {
|
||||
assertCacheKey(key)
|
||||
|
||||
const topLevelKey = `${key.origin}:${key.path}`
|
||||
|
||||
const now = Date.now()
|
||||
const entries = this.#entries.get(topLevelKey)
|
||||
|
||||
const entry = entries ? findEntry(key, entries, now) : null
|
||||
|
||||
return entry == null
|
||||
? undefined
|
||||
: {
|
||||
statusMessage: entry.statusMessage,
|
||||
statusCode: entry.statusCode,
|
||||
headers: entry.headers,
|
||||
body: entry.body,
|
||||
vary: entry.vary ? entry.vary : undefined,
|
||||
etag: entry.etag,
|
||||
cacheControlDirectives: entry.cacheControlDirectives,
|
||||
cachedAt: entry.cachedAt,
|
||||
staleAt: entry.staleAt,
|
||||
deleteAt: entry.deleteAt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheValue} val
|
||||
* @returns {Writable | undefined}
|
||||
*/
|
||||
createWriteStream (key, val) {
|
||||
assertCacheKey(key)
|
||||
assertCacheValue(val)
|
||||
|
||||
const topLevelKey = `${key.origin}:${key.path}`
|
||||
|
||||
const store = this
|
||||
const entry = { ...key, ...val, body: [], size: 0 }
|
||||
|
||||
return new Writable({
|
||||
write (chunk, encoding, callback) {
|
||||
if (typeof chunk === 'string') {
|
||||
chunk = Buffer.from(chunk, encoding)
|
||||
}
|
||||
|
||||
entry.size += chunk.byteLength
|
||||
|
||||
if (entry.size > store.#maxEntrySize) {
|
||||
this.destroy()
|
||||
} else {
|
||||
entry.body.push(chunk)
|
||||
}
|
||||
|
||||
callback(null)
|
||||
},
|
||||
final (callback) {
|
||||
let entries = store.#entries.get(topLevelKey)
|
||||
if (!entries) {
|
||||
entries = []
|
||||
store.#entries.set(topLevelKey, entries)
|
||||
}
|
||||
const previousEntry = findEntry(key, entries, Date.now())
|
||||
if (previousEntry) {
|
||||
const index = entries.indexOf(previousEntry)
|
||||
entries.splice(index, 1, entry)
|
||||
store.#size -= previousEntry.size
|
||||
} else {
|
||||
entries.push(entry)
|
||||
store.#count += 1
|
||||
}
|
||||
|
||||
store.#size += entry.size
|
||||
|
||||
// Check if cache is full and emit event if needed
|
||||
if (store.#size > store.#maxSize || store.#count > store.#maxCount) {
|
||||
// Emit maxSizeExceeded event if we haven't already
|
||||
if (!store.#hasEmittedMaxSizeEvent) {
|
||||
store.emit('maxSizeExceeded', {
|
||||
size: store.#size,
|
||||
maxSize: store.#maxSize,
|
||||
count: store.#count,
|
||||
maxCount: store.#maxCount
|
||||
})
|
||||
store.#hasEmittedMaxSizeEvent = true
|
||||
}
|
||||
|
||||
// Perform eviction
|
||||
for (const [key, entries] of store.#entries) {
|
||||
for (const entry of entries.splice(0, Math.ceil(entries.length / 2))) {
|
||||
store.#size -= entry.size
|
||||
store.#count -= 1
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
store.#entries.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the event flag after eviction
|
||||
if (store.#size < store.#maxSize && store.#count < store.#maxCount) {
|
||||
store.#hasEmittedMaxSizeEvent = false
|
||||
}
|
||||
}
|
||||
|
||||
callback(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CacheKey} key
|
||||
*/
|
||||
delete (key) {
|
||||
if (typeof key !== 'object') {
|
||||
throw new TypeError(`expected key to be object, got ${typeof key}`)
|
||||
}
|
||||
|
||||
const topLevelKey = `${key.origin}:${key.path}`
|
||||
|
||||
for (const entry of this.#entries.get(topLevelKey) ?? []) {
|
||||
this.#size -= entry.size
|
||||
this.#count -= 1
|
||||
}
|
||||
this.#entries.delete(topLevelKey)
|
||||
}
|
||||
}
|
||||
|
||||
function findEntry (key, entries, now) {
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i]
|
||||
if (
|
||||
entry.deleteAt > now &&
|
||||
entry.method === key.method &&
|
||||
varyMatches(key, entry)
|
||||
) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function varyMatches (key, entry) {
|
||||
if (entry.vary == null) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (const headerName in entry.vary) {
|
||||
if (Object.hasOwn(entry.vary, headerName) && !headerValueEquals(key.headers?.[headerName], entry.vary[headerName])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|string[]|null|undefined} lhs
|
||||
* @param {string|string[]|null|undefined} rhs
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function headerValueEquals (lhs, rhs) {
|
||||
if (lhs == null && rhs == null) {
|
||||
return true
|
||||
}
|
||||
|
||||
if ((lhs == null && rhs != null) ||
|
||||
(lhs != null && rhs == null)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Array.isArray(lhs) && Array.isArray(rhs)) {
|
||||
if (lhs.length !== rhs.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let i = 0; i < lhs.length; i++) {
|
||||
if (lhs[i] !== rhs[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return lhs === rhs
|
||||
}
|
||||
|
||||
module.exports = MemoryCacheStore
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
'use strict'
|
||||
|
||||
const { Writable } = require('node:stream')
|
||||
const { assertCacheKey, assertCacheValue } = require('../util/cache.js')
|
||||
|
||||
let DatabaseSync
|
||||
|
||||
const VERSION = 3
|
||||
|
||||
// 2gb
|
||||
const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000
|
||||
|
||||
/**
|
||||
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
|
||||
* @implements {CacheStore}
|
||||
*
|
||||
* @typedef {{
|
||||
* id: Readonly<number>,
|
||||
* body?: Uint8Array
|
||||
* statusCode: number
|
||||
* statusMessage: string
|
||||
* headers?: string
|
||||
* vary?: string
|
||||
* etag?: string
|
||||
* cacheControlDirectives?: string
|
||||
* cachedAt: number
|
||||
* staleAt: number
|
||||
* deleteAt: number
|
||||
* }} SqliteStoreValue
|
||||
*/
|
||||
module.exports = class SqliteCacheStore {
|
||||
#maxEntrySize = MAX_ENTRY_SIZE
|
||||
#maxCount = Infinity
|
||||
|
||||
/**
|
||||
* @type {import('node:sqlite').DatabaseSync}
|
||||
*/
|
||||
#db
|
||||
|
||||
/**
|
||||
* @type {import('node:sqlite').StatementSync}
|
||||
*/
|
||||
#getValuesQuery
|
||||
|
||||
/**
|
||||
* @type {import('node:sqlite').StatementSync}
|
||||
*/
|
||||
#updateValueQuery
|
||||
|
||||
/**
|
||||
* @type {import('node:sqlite').StatementSync}
|
||||
*/
|
||||
#insertValueQuery
|
||||
|
||||
/**
|
||||
* @type {import('node:sqlite').StatementSync}
|
||||
*/
|
||||
#deleteExpiredValuesQuery
|
||||
|
||||
/**
|
||||
* @type {import('node:sqlite').StatementSync}
|
||||
*/
|
||||
#deleteByUrlQuery
|
||||
|
||||
/**
|
||||
* @type {import('node:sqlite').StatementSync}
|
||||
*/
|
||||
#countEntriesQuery
|
||||
|
||||
/**
|
||||
* @type {import('node:sqlite').StatementSync | null}
|
||||
*/
|
||||
#deleteOldValuesQuery
|
||||
|
||||
/**
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.SqliteCacheStoreOpts | undefined} opts
|
||||
*/
|
||||
constructor (opts) {
|
||||
if (opts) {
|
||||
if (typeof opts !== 'object') {
|
||||
throw new TypeError('SqliteCacheStore options must be an object')
|
||||
}
|
||||
|
||||
if (opts.maxEntrySize !== undefined) {
|
||||
if (
|
||||
typeof opts.maxEntrySize !== 'number' ||
|
||||
!Number.isInteger(opts.maxEntrySize) ||
|
||||
opts.maxEntrySize < 0
|
||||
) {
|
||||
throw new TypeError('SqliteCacheStore options.maxEntrySize must be a non-negative integer')
|
||||
}
|
||||
|
||||
if (opts.maxEntrySize > MAX_ENTRY_SIZE) {
|
||||
throw new TypeError('SqliteCacheStore options.maxEntrySize must be less than 2gb')
|
||||
}
|
||||
|
||||
this.#maxEntrySize = opts.maxEntrySize
|
||||
}
|
||||
|
||||
if (opts.maxCount !== undefined) {
|
||||
if (
|
||||
typeof opts.maxCount !== 'number' ||
|
||||
!Number.isInteger(opts.maxCount) ||
|
||||
opts.maxCount < 0
|
||||
) {
|
||||
throw new TypeError('SqliteCacheStore options.maxCount must be a non-negative integer')
|
||||
}
|
||||
this.#maxCount = opts.maxCount
|
||||
}
|
||||
}
|
||||
|
||||
if (!DatabaseSync) {
|
||||
DatabaseSync = require('node:sqlite').DatabaseSync
|
||||
}
|
||||
this.#db = new DatabaseSync(opts?.location ?? ':memory:')
|
||||
|
||||
this.#db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA temp_store = memory;
|
||||
PRAGMA optimize;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cacheInterceptorV${VERSION} (
|
||||
-- Data specific to us
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
|
||||
-- Data returned to the interceptor
|
||||
body BUF NULL,
|
||||
deleteAt INTEGER NOT NULL,
|
||||
statusCode INTEGER NOT NULL,
|
||||
statusMessage TEXT NOT NULL,
|
||||
headers TEXT NULL,
|
||||
cacheControlDirectives TEXT NULL,
|
||||
etag TEXT NULL,
|
||||
vary TEXT NULL,
|
||||
cachedAt INTEGER NOT NULL,
|
||||
staleAt INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_getValuesQuery ON cacheInterceptorV${VERSION}(url, method, deleteAt);
|
||||
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_deleteByUrlQuery ON cacheInterceptorV${VERSION}(deleteAt);
|
||||
`)
|
||||
|
||||
this.#getValuesQuery = this.#db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
body,
|
||||
deleteAt,
|
||||
statusCode,
|
||||
statusMessage,
|
||||
headers,
|
||||
etag,
|
||||
cacheControlDirectives,
|
||||
vary,
|
||||
cachedAt,
|
||||
staleAt
|
||||
FROM cacheInterceptorV${VERSION}
|
||||
WHERE
|
||||
url = ?
|
||||
AND method = ?
|
||||
ORDER BY
|
||||
deleteAt ASC
|
||||
`)
|
||||
|
||||
this.#updateValueQuery = this.#db.prepare(`
|
||||
UPDATE cacheInterceptorV${VERSION} SET
|
||||
body = ?,
|
||||
deleteAt = ?,
|
||||
statusCode = ?,
|
||||
statusMessage = ?,
|
||||
headers = ?,
|
||||
etag = ?,
|
||||
cacheControlDirectives = ?,
|
||||
vary = ?,
|
||||
cachedAt = ?,
|
||||
staleAt = ?
|
||||
WHERE
|
||||
id = ?
|
||||
`)
|
||||
|
||||
this.#insertValueQuery = this.#db.prepare(`
|
||||
INSERT INTO cacheInterceptorV${VERSION} (
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
deleteAt,
|
||||
statusCode,
|
||||
statusMessage,
|
||||
headers,
|
||||
etag,
|
||||
cacheControlDirectives,
|
||||
vary,
|
||||
cachedAt,
|
||||
staleAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
this.#deleteByUrlQuery = this.#db.prepare(
|
||||
`DELETE FROM cacheInterceptorV${VERSION} WHERE url = ?`
|
||||
)
|
||||
|
||||
this.#countEntriesQuery = this.#db.prepare(
|
||||
`SELECT COUNT(*) AS total FROM cacheInterceptorV${VERSION}`
|
||||
)
|
||||
|
||||
this.#deleteExpiredValuesQuery = this.#db.prepare(
|
||||
`DELETE FROM cacheInterceptorV${VERSION} WHERE deleteAt <= ?`
|
||||
)
|
||||
|
||||
this.#deleteOldValuesQuery = this.#maxCount === Infinity
|
||||
? null
|
||||
: this.#db.prepare(`
|
||||
DELETE FROM cacheInterceptorV${VERSION}
|
||||
WHERE id IN (
|
||||
SELECT
|
||||
id
|
||||
FROM cacheInterceptorV${VERSION}
|
||||
ORDER BY cachedAt ASC
|
||||
LIMIT ?
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
close () {
|
||||
this.#db.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
|
||||
* @returns {(import('../../types/cache-interceptor.d.ts').default.GetResult & { body?: Buffer }) | undefined}
|
||||
*/
|
||||
get (key) {
|
||||
assertCacheKey(key)
|
||||
|
||||
const value = this.#findValue(key)
|
||||
return value
|
||||
? {
|
||||
body: value.body ? Buffer.from(value.body.buffer, value.body.byteOffset, value.body.byteLength) : undefined,
|
||||
statusCode: value.statusCode,
|
||||
statusMessage: value.statusMessage,
|
||||
headers: value.headers ? JSON.parse(value.headers) : undefined,
|
||||
etag: value.etag ? value.etag : undefined,
|
||||
vary: value.vary ? JSON.parse(value.vary) : undefined,
|
||||
cacheControlDirectives: value.cacheControlDirectives
|
||||
? JSON.parse(value.cacheControlDirectives)
|
||||
: undefined,
|
||||
cachedAt: value.cachedAt,
|
||||
staleAt: value.staleAt,
|
||||
deleteAt: value.deleteAt
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheValue & { body: null | Buffer | Array<Buffer>}} value
|
||||
*/
|
||||
set (key, value) {
|
||||
assertCacheKey(key)
|
||||
|
||||
const url = this.#makeValueUrl(key)
|
||||
const body = Array.isArray(value.body) ? Buffer.concat(value.body) : value.body
|
||||
const size = body?.byteLength
|
||||
|
||||
if (size && size > this.#maxEntrySize) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingValue = this.#findValue(key, true)
|
||||
if (existingValue) {
|
||||
// Updating an existing response, let's overwrite it
|
||||
this.#updateValueQuery.run(
|
||||
body,
|
||||
value.deleteAt,
|
||||
value.statusCode,
|
||||
value.statusMessage,
|
||||
value.headers ? JSON.stringify(value.headers) : null,
|
||||
value.etag ? value.etag : null,
|
||||
value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null,
|
||||
value.vary ? JSON.stringify(value.vary) : null,
|
||||
value.cachedAt,
|
||||
value.staleAt,
|
||||
existingValue.id
|
||||
)
|
||||
} else {
|
||||
// New response, let's insert it
|
||||
this.#insertValueQuery.run(
|
||||
url,
|
||||
key.method,
|
||||
body,
|
||||
value.deleteAt,
|
||||
value.statusCode,
|
||||
value.statusMessage,
|
||||
value.headers ? JSON.stringify(value.headers) : null,
|
||||
value.etag ? value.etag : null,
|
||||
value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null,
|
||||
value.vary ? JSON.stringify(value.vary) : null,
|
||||
value.cachedAt,
|
||||
value.staleAt
|
||||
)
|
||||
this.#prune()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheValue} value
|
||||
* @returns {Writable | undefined}
|
||||
*/
|
||||
createWriteStream (key, value) {
|
||||
assertCacheKey(key)
|
||||
assertCacheValue(value)
|
||||
|
||||
let size = 0
|
||||
/**
|
||||
* @type {Buffer[] | null}
|
||||
*/
|
||||
const body = []
|
||||
const store = this
|
||||
|
||||
return new Writable({
|
||||
decodeStrings: true,
|
||||
write (chunk, encoding, callback) {
|
||||
size += chunk.byteLength
|
||||
|
||||
if (size <= store.#maxEntrySize) {
|
||||
body.push(chunk)
|
||||
} else {
|
||||
this.destroy()
|
||||
}
|
||||
|
||||
callback()
|
||||
},
|
||||
final (callback) {
|
||||
store.set(key, { ...value, body })
|
||||
callback()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
|
||||
*/
|
||||
delete (key) {
|
||||
if (typeof key !== 'object') {
|
||||
throw new TypeError(`expected key to be object, got ${typeof key}`)
|
||||
}
|
||||
|
||||
this.#deleteByUrlQuery.run(this.#makeValueUrl(key))
|
||||
}
|
||||
|
||||
#prune () {
|
||||
if (Number.isFinite(this.#maxCount) && this.size <= this.#maxCount) {
|
||||
return 0
|
||||
}
|
||||
|
||||
{
|
||||
const removed = this.#deleteExpiredValuesQuery.run(Date.now()).changes
|
||||
if (removed) {
|
||||
return removed
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const removed = this.#deleteOldValuesQuery?.run(Math.max(Math.floor(this.#maxCount * 0.1), 1)).changes
|
||||
if (removed) {
|
||||
return removed
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of rows in the cache
|
||||
* @returns {Number}
|
||||
*/
|
||||
get size () {
|
||||
const { total } = this.#countEntriesQuery.get()
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
|
||||
* @returns {string}
|
||||
*/
|
||||
#makeValueUrl (key) {
|
||||
return `${key.origin}/${key.path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
|
||||
* @param {boolean} [canBeExpired=false]
|
||||
* @returns {SqliteStoreValue | undefined}
|
||||
*/
|
||||
#findValue (key, canBeExpired = false) {
|
||||
const url = this.#makeValueUrl(key)
|
||||
const { headers, method } = key
|
||||
|
||||
/**
|
||||
* @type {SqliteStoreValue[]}
|
||||
*/
|
||||
const values = this.#getValuesQuery.all(url, method)
|
||||
|
||||
if (values.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
for (const value of values) {
|
||||
if (now >= value.deleteAt && !canBeExpired) {
|
||||
continue
|
||||
}
|
||||
|
||||
let matches = true
|
||||
|
||||
if (value.vary) {
|
||||
const vary = JSON.parse(value.vary)
|
||||
|
||||
for (const header in vary) {
|
||||
if (!headerValueEquals(headers[header], vary[header])) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|string[]|null|undefined} lhs
|
||||
* @param {string|string[]|null|undefined} rhs
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function headerValueEquals (lhs, rhs) {
|
||||
if (lhs == null && rhs == null) {
|
||||
return true
|
||||
}
|
||||
|
||||
if ((lhs == null && rhs != null) ||
|
||||
(lhs != null && rhs == null)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Array.isArray(lhs) && Array.isArray(rhs)) {
|
||||
if (lhs.length !== rhs.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let i = 0; i < lhs.length; i++) {
|
||||
if (lhs[i] !== rhs[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return lhs === rhs
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
'use strict'
|
||||
|
||||
const net = require('node:net')
|
||||
const assert = require('node:assert')
|
||||
const util = require('./util')
|
||||
const { InvalidArgumentError, ConnectTimeoutError } = require('./errors')
|
||||
|
||||
let tls // include tls conditionally since it is not always available
|
||||
|
||||
// TODO: session re-use does not wait for the first
|
||||
// connection to resolve the session and might therefore
|
||||
// resolve the same servername multiple times even when
|
||||
// re-use is enabled.
|
||||
|
||||
const SessionCache = class WeakSessionCache {
|
||||
constructor (maxCachedSessions) {
|
||||
this._maxCachedSessions = maxCachedSessions
|
||||
this._sessionCache = new Map()
|
||||
this._sessionRegistry = new FinalizationRegistry((key) => {
|
||||
if (this._sessionCache.size < this._maxCachedSessions) {
|
||||
return
|
||||
}
|
||||
|
||||
const ref = this._sessionCache.get(key)
|
||||
if (ref !== undefined && ref.deref() === undefined) {
|
||||
this._sessionCache.delete(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
get (sessionKey) {
|
||||
const ref = this._sessionCache.get(sessionKey)
|
||||
return ref ? ref.deref() : null
|
||||
}
|
||||
|
||||
set (sessionKey, session) {
|
||||
if (this._maxCachedSessions === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this._sessionCache.has(sessionKey)) {
|
||||
this._sessionCache.delete(sessionKey)
|
||||
} else if (this._sessionCache.size >= this._maxCachedSessions) {
|
||||
for (const [key, ref] of this._sessionCache) {
|
||||
if (ref.deref() === undefined) {
|
||||
this._sessionCache.delete(key)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const oldest = this._sessionCache.keys().next()
|
||||
if (!oldest.done) {
|
||||
this._sessionCache.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
|
||||
this._sessionCache.set(sessionKey, new WeakRef(session))
|
||||
this._sessionRegistry.register(session, sessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
function buildConnector ({ allowH2, preferH2, useH2c, maxCachedSessions, socketPath, timeout, session: customSession, ...opts }) {
|
||||
if (maxCachedSessions != null && (!Number.isInteger(maxCachedSessions) || maxCachedSessions < 0)) {
|
||||
throw new InvalidArgumentError('maxCachedSessions must be a positive integer or zero')
|
||||
}
|
||||
|
||||
const options = { path: socketPath, ...opts }
|
||||
const sessionCache = new SessionCache(maxCachedSessions == null ? 100 : maxCachedSessions)
|
||||
timeout = timeout == null ? 10e3 : timeout
|
||||
allowH2 = allowH2 != null ? allowH2 : true
|
||||
return function connect ({ hostname, host, protocol, port, servername, localAddress, httpSocket }, callback) {
|
||||
let socket
|
||||
if (protocol === 'https:') {
|
||||
if (!tls) {
|
||||
tls = require('node:tls')
|
||||
}
|
||||
servername = servername || options.servername || util.getServerName(host) || null
|
||||
|
||||
const sessionKey = servername || hostname
|
||||
assert(sessionKey)
|
||||
|
||||
const session = customSession || sessionCache.get(sessionKey) || null
|
||||
|
||||
port = port || 443
|
||||
|
||||
socket = tls.connect({
|
||||
highWaterMark: 16384, // TLS in node can't have bigger HWM anyway...
|
||||
...options,
|
||||
servername,
|
||||
session,
|
||||
localAddress,
|
||||
ALPNProtocols: allowH2 ? (preferH2 ? ['h2', 'http/1.1'] : ['http/1.1', 'h2']) : ['http/1.1'],
|
||||
socket: httpSocket, // upgrade socket connection
|
||||
port,
|
||||
host: hostname
|
||||
})
|
||||
|
||||
socket
|
||||
.on('session', function (session) {
|
||||
// TODO (fix): Can a session become invalid once established? Don't think so?
|
||||
sessionCache.set(sessionKey, session)
|
||||
})
|
||||
} else {
|
||||
assert(!httpSocket, 'httpSocket can only be sent on TLS update')
|
||||
|
||||
port = port || 80
|
||||
|
||||
const connectOptions = {
|
||||
highWaterMark: 64 * 1024, // Same as nodejs fs streams.
|
||||
...options,
|
||||
localAddress,
|
||||
port,
|
||||
host: hostname
|
||||
}
|
||||
|
||||
const family = net.isIP(hostname)
|
||||
if (family !== 0 && servername && servername !== hostname) {
|
||||
connectOptions.host = servername
|
||||
connectOptions.lookup = (_hostname, lookupOptions, cb) => {
|
||||
if (lookupOptions.all) {
|
||||
cb(null, [{ address: hostname, family }])
|
||||
} else {
|
||||
cb(null, hostname, family)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
socket = net.connect(connectOptions)
|
||||
if (useH2c === true) {
|
||||
socket.alpnProtocol = 'h2'
|
||||
}
|
||||
}
|
||||
|
||||
// Set TCP keep alive options on the socket here instead of in connect() for the case of assigning the socket
|
||||
if (options.keepAlive == null || options.keepAlive) {
|
||||
const keepAliveInitialDelay = options.keepAliveInitialDelay === undefined ? 60e3 : options.keepAliveInitialDelay
|
||||
socket.setKeepAlive(true, keepAliveInitialDelay)
|
||||
}
|
||||
|
||||
const clearConnectTimeout = util.setupConnectTimeout(new WeakRef(socket), { timeout, hostname, port })
|
||||
|
||||
socket
|
||||
.setNoDelay(true)
|
||||
.once(protocol === 'https:' ? 'secureConnect' : 'connect', function () {
|
||||
queueMicrotask(clearConnectTimeout)
|
||||
|
||||
if (callback) {
|
||||
const cb = callback
|
||||
callback = null
|
||||
cb(null, this)
|
||||
}
|
||||
})
|
||||
.on('error', function (err) {
|
||||
queueMicrotask(clearConnectTimeout)
|
||||
|
||||
if (callback) {
|
||||
const cb = callback
|
||||
callback = null
|
||||
cb(maybeNormalizeConnectError(err, this, { timeout, hostname, port }))
|
||||
}
|
||||
})
|
||||
|
||||
return socket
|
||||
}
|
||||
}
|
||||
|
||||
// `net.connect` with `autoSelectFamily` raises an `AggregateError` when every
|
||||
// attempted address fails. If any of those failures is a timeout, surface the
|
||||
// error as a `ConnectTimeoutError` so callers see the same error regardless of
|
||||
// which timer (Node's internal one or undici's `connectTimeout`) wins the race.
|
||||
// The original `AggregateError` is preserved on `.cause`.
|
||||
function maybeNormalizeConnectError (err, socket, opts) {
|
||||
if (
|
||||
err instanceof AggregateError &&
|
||||
(err.code === 'ETIMEDOUT' || err.errors.some((e) => e != null && e.code === 'ETIMEDOUT'))
|
||||
) {
|
||||
let message = 'Connect Timeout Error'
|
||||
if (Array.isArray(socket.autoSelectFamilyAttemptedAddresses)) {
|
||||
message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')},`
|
||||
} else {
|
||||
message += ` (attempted address: ${opts.hostname}:${opts.port},`
|
||||
}
|
||||
message += ` timeout: ${opts.timeout}ms)`
|
||||
|
||||
const wrapped = new ConnectTimeoutError(message)
|
||||
wrapped.cause = err
|
||||
return wrapped
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
module.exports = buildConnector
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* @see https://developer.mozilla.org/docs/Web/HTTP/Headers
|
||||
*/
|
||||
const wellknownHeaderNames = /** @type {const} */ ([
|
||||
'Accept',
|
||||
'Accept-Encoding',
|
||||
'Accept-Language',
|
||||
'Accept-Ranges',
|
||||
'Access-Control-Allow-Credentials',
|
||||
'Access-Control-Allow-Headers',
|
||||
'Access-Control-Allow-Methods',
|
||||
'Access-Control-Allow-Origin',
|
||||
'Access-Control-Expose-Headers',
|
||||
'Access-Control-Max-Age',
|
||||
'Access-Control-Request-Headers',
|
||||
'Access-Control-Request-Method',
|
||||
'Age',
|
||||
'Allow',
|
||||
'Alt-Svc',
|
||||
'Alt-Used',
|
||||
'Authorization',
|
||||
'Cache-Control',
|
||||
'Clear-Site-Data',
|
||||
'Connection',
|
||||
'Content-Disposition',
|
||||
'Content-Encoding',
|
||||
'Content-Language',
|
||||
'Content-Length',
|
||||
'Content-Location',
|
||||
'Content-Range',
|
||||
'Content-Security-Policy',
|
||||
'Content-Security-Policy-Report-Only',
|
||||
'Content-Type',
|
||||
'Cookie',
|
||||
'Cross-Origin-Embedder-Policy',
|
||||
'Cross-Origin-Opener-Policy',
|
||||
'Cross-Origin-Resource-Policy',
|
||||
'Date',
|
||||
'Device-Memory',
|
||||
'Downlink',
|
||||
'ECT',
|
||||
'ETag',
|
||||
'Expect',
|
||||
'Expect-CT',
|
||||
'Expires',
|
||||
'Forwarded',
|
||||
'From',
|
||||
'Host',
|
||||
'If-Match',
|
||||
'If-Modified-Since',
|
||||
'If-None-Match',
|
||||
'If-Range',
|
||||
'If-Unmodified-Since',
|
||||
'Keep-Alive',
|
||||
'Last-Modified',
|
||||
'Link',
|
||||
'Location',
|
||||
'Max-Forwards',
|
||||
'Origin',
|
||||
'Permissions-Policy',
|
||||
'Pragma',
|
||||
'Proxy-Authenticate',
|
||||
'Proxy-Authorization',
|
||||
'RTT',
|
||||
'Range',
|
||||
'Referer',
|
||||
'Referrer-Policy',
|
||||
'Refresh',
|
||||
'Retry-After',
|
||||
'Sec-WebSocket-Accept',
|
||||
'Sec-WebSocket-Extensions',
|
||||
'Sec-WebSocket-Key',
|
||||
'Sec-WebSocket-Protocol',
|
||||
'Sec-WebSocket-Version',
|
||||
'Server',
|
||||
'Server-Timing',
|
||||
'Service-Worker-Allowed',
|
||||
'Service-Worker-Navigation-Preload',
|
||||
'Set-Cookie',
|
||||
'SourceMap',
|
||||
'Strict-Transport-Security',
|
||||
'Supports-Loading-Mode',
|
||||
'TE',
|
||||
'Timing-Allow-Origin',
|
||||
'Trailer',
|
||||
'Transfer-Encoding',
|
||||
'Upgrade',
|
||||
'Upgrade-Insecure-Requests',
|
||||
'User-Agent',
|
||||
'Vary',
|
||||
'Via',
|
||||
'WWW-Authenticate',
|
||||
'X-Content-Type-Options',
|
||||
'X-DNS-Prefetch-Control',
|
||||
'X-Frame-Options',
|
||||
'X-Permitted-Cross-Domain-Policies',
|
||||
'X-Powered-By',
|
||||
'X-Requested-With',
|
||||
'X-XSS-Protection'
|
||||
])
|
||||
|
||||
/** @type {Record<typeof wellknownHeaderNames[number]|Lowercase<typeof wellknownHeaderNames[number]>, string>} */
|
||||
const headerNameLowerCasedRecord = {}
|
||||
|
||||
// Note: object prototypes should not be able to be referenced. e.g. `Object#hasOwnProperty`.
|
||||
Object.setPrototypeOf(headerNameLowerCasedRecord, null)
|
||||
|
||||
for (let i = 0; i < wellknownHeaderNames.length; ++i) {
|
||||
const key = wellknownHeaderNames[i]
|
||||
const lowerCasedKey = key.toLowerCase()
|
||||
headerNameLowerCasedRecord[key] = headerNameLowerCasedRecord[lowerCasedKey] =
|
||||
lowerCasedKey
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
wellknownHeaderNames,
|
||||
headerNameLowerCasedRecord
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
'use strict'
|
||||
|
||||
const diagnosticsChannel = require('node:diagnostics_channel')
|
||||
const util = require('node:util')
|
||||
|
||||
const undiciDebugLog = util.debuglog('undici')
|
||||
const fetchDebuglog = util.debuglog('fetch')
|
||||
const websocketDebuglog = util.debuglog('websocket')
|
||||
|
||||
const channels = {
|
||||
// Client
|
||||
beforeConnect: diagnosticsChannel.channel('undici:client:beforeConnect'),
|
||||
connected: diagnosticsChannel.channel('undici:client:connected'),
|
||||
connectError: diagnosticsChannel.channel('undici:client:connectError'),
|
||||
sendHeaders: diagnosticsChannel.channel('undici:client:sendHeaders'),
|
||||
// Request
|
||||
create: diagnosticsChannel.channel('undici:request:create'),
|
||||
bodySent: diagnosticsChannel.channel('undici:request:bodySent'),
|
||||
bodyChunkSent: diagnosticsChannel.channel('undici:request:bodyChunkSent'),
|
||||
bodyChunkReceived: diagnosticsChannel.channel('undici:request:bodyChunkReceived'),
|
||||
headers: diagnosticsChannel.channel('undici:request:headers'),
|
||||
trailers: diagnosticsChannel.channel('undici:request:trailers'),
|
||||
error: diagnosticsChannel.channel('undici:request:error'),
|
||||
// WebSocket
|
||||
open: diagnosticsChannel.channel('undici:websocket:open'),
|
||||
close: diagnosticsChannel.channel('undici:websocket:close'),
|
||||
socketError: diagnosticsChannel.channel('undici:websocket:socket_error'),
|
||||
ping: diagnosticsChannel.channel('undici:websocket:ping'),
|
||||
pong: diagnosticsChannel.channel('undici:websocket:pong'),
|
||||
// ProxyAgent
|
||||
proxyConnected: diagnosticsChannel.channel('undici:proxy:connected')
|
||||
}
|
||||
|
||||
let isTrackingClientEvents = false
|
||||
|
||||
function trackClientEvents (debugLog = undiciDebugLog) {
|
||||
if (isTrackingClientEvents) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if any of the channels already have subscribers to prevent duplicate subscriptions
|
||||
// This can happen when both Node.js built-in undici and undici as a dependency are present
|
||||
if (channels.beforeConnect.hasSubscribers || channels.connected.hasSubscribers ||
|
||||
channels.connectError.hasSubscribers || channels.sendHeaders.hasSubscribers) {
|
||||
isTrackingClientEvents = true
|
||||
return
|
||||
}
|
||||
|
||||
isTrackingClientEvents = true
|
||||
|
||||
diagnosticsChannel.subscribe('undici:client:beforeConnect',
|
||||
evt => {
|
||||
const {
|
||||
connectParams: { version, protocol, port, host }
|
||||
} = evt
|
||||
debugLog(
|
||||
'connecting to %s%s using %s%s',
|
||||
host,
|
||||
port ? `:${port}` : '',
|
||||
protocol,
|
||||
version
|
||||
)
|
||||
})
|
||||
|
||||
diagnosticsChannel.subscribe('undici:client:connected',
|
||||
evt => {
|
||||
const {
|
||||
connectParams: { version, protocol, port, host }
|
||||
} = evt
|
||||
debugLog(
|
||||
'connected to %s%s using %s%s',
|
||||
host,
|
||||
port ? `:${port}` : '',
|
||||
protocol,
|
||||
version
|
||||
)
|
||||
})
|
||||
|
||||
diagnosticsChannel.subscribe('undici:client:connectError',
|
||||
evt => {
|
||||
const {
|
||||
connectParams: { version, protocol, port, host },
|
||||
error
|
||||
} = evt
|
||||
debugLog(
|
||||
'connection to %s%s using %s%s errored - %s',
|
||||
host,
|
||||
port ? `:${port}` : '',
|
||||
protocol,
|
||||
version,
|
||||
error.message
|
||||
)
|
||||
})
|
||||
|
||||
diagnosticsChannel.subscribe('undici:client:sendHeaders',
|
||||
evt => {
|
||||
const {
|
||||
request: { method, path, origin }
|
||||
} = evt
|
||||
debugLog('sending request to %s %s%s', method, origin, path)
|
||||
})
|
||||
}
|
||||
|
||||
let isTrackingRequestEvents = false
|
||||
|
||||
function trackRequestEvents (debugLog = undiciDebugLog) {
|
||||
if (isTrackingRequestEvents) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if any of the channels already have subscribers to prevent duplicate subscriptions
|
||||
// This can happen when both Node.js built-in undici and undici as a dependency are present
|
||||
if (channels.headers.hasSubscribers || channels.trailers.hasSubscribers ||
|
||||
channels.error.hasSubscribers) {
|
||||
isTrackingRequestEvents = true
|
||||
return
|
||||
}
|
||||
|
||||
isTrackingRequestEvents = true
|
||||
|
||||
diagnosticsChannel.subscribe('undici:request:headers',
|
||||
evt => {
|
||||
const {
|
||||
request: { method, path, origin },
|
||||
response: { statusCode }
|
||||
} = evt
|
||||
debugLog(
|
||||
'received response to %s %s%s - HTTP %d',
|
||||
method,
|
||||
origin,
|
||||
path,
|
||||
statusCode
|
||||
)
|
||||
})
|
||||
|
||||
diagnosticsChannel.subscribe('undici:request:trailers',
|
||||
evt => {
|
||||
const {
|
||||
request: { method, path, origin }
|
||||
} = evt
|
||||
debugLog('trailers received from %s %s%s', method, origin, path)
|
||||
})
|
||||
|
||||
diagnosticsChannel.subscribe('undici:request:error',
|
||||
evt => {
|
||||
const {
|
||||
request: { method, path, origin },
|
||||
error
|
||||
} = evt
|
||||
debugLog(
|
||||
'request to %s %s%s errored - %s',
|
||||
method,
|
||||
origin,
|
||||
path,
|
||||
error.message
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
let isTrackingWebSocketEvents = false
|
||||
|
||||
function trackWebSocketEvents (debugLog = websocketDebuglog) {
|
||||
if (isTrackingWebSocketEvents) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if any of the channels already have subscribers to prevent duplicate subscriptions
|
||||
// This can happen when both Node.js built-in undici and undici as a dependency are present
|
||||
if (channels.open.hasSubscribers || channels.close.hasSubscribers ||
|
||||
channels.socketError.hasSubscribers || channels.ping.hasSubscribers ||
|
||||
channels.pong.hasSubscribers) {
|
||||
isTrackingWebSocketEvents = true
|
||||
return
|
||||
}
|
||||
|
||||
isTrackingWebSocketEvents = true
|
||||
|
||||
diagnosticsChannel.subscribe('undici:websocket:open',
|
||||
evt => {
|
||||
if (evt.address != null) {
|
||||
const { address, port } = evt.address
|
||||
debugLog('connection opened %s%s', address, port ? `:${port}` : '')
|
||||
} else {
|
||||
debugLog('connection opened')
|
||||
}
|
||||
})
|
||||
|
||||
diagnosticsChannel.subscribe('undici:websocket:close',
|
||||
evt => {
|
||||
const { websocket, code, reason } = evt
|
||||
debugLog(
|
||||
'closed connection to %s - %s %s',
|
||||
websocket.url,
|
||||
code,
|
||||
reason
|
||||
)
|
||||
})
|
||||
|
||||
diagnosticsChannel.subscribe('undici:websocket:socket_error',
|
||||
err => {
|
||||
debugLog('connection errored - %s', err.message)
|
||||
})
|
||||
|
||||
diagnosticsChannel.subscribe('undici:websocket:ping',
|
||||
evt => {
|
||||
debugLog('ping received')
|
||||
})
|
||||
|
||||
diagnosticsChannel.subscribe('undici:websocket:pong',
|
||||
evt => {
|
||||
debugLog('pong received')
|
||||
})
|
||||
}
|
||||
|
||||
if (undiciDebugLog.enabled || fetchDebuglog.enabled) {
|
||||
trackClientEvents(fetchDebuglog.enabled ? fetchDebuglog : undiciDebugLog)
|
||||
trackRequestEvents(fetchDebuglog.enabled ? fetchDebuglog : undiciDebugLog)
|
||||
}
|
||||
|
||||
if (websocketDebuglog.enabled) {
|
||||
trackClientEvents(undiciDebugLog.enabled ? undiciDebugLog : websocketDebuglog)
|
||||
trackWebSocketEvents(websocketDebuglog)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
channels
|
||||
}
|
||||
+497
@@ -0,0 +1,497 @@
|
||||
'use strict'
|
||||
|
||||
const kUndiciError = Symbol.for('undici.error.UND_ERR')
|
||||
class UndiciError extends Error {
|
||||
constructor (message, options) {
|
||||
super(message, options)
|
||||
this.name = 'UndiciError'
|
||||
this.code = 'UND_ERR'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kUndiciError] === true
|
||||
}
|
||||
|
||||
get [kUndiciError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kConnectTimeoutError = Symbol.for('undici.error.UND_ERR_CONNECT_TIMEOUT')
|
||||
class ConnectTimeoutError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'ConnectTimeoutError'
|
||||
this.message = message || 'Connect Timeout Error'
|
||||
this.code = 'UND_ERR_CONNECT_TIMEOUT'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kConnectTimeoutError] === true
|
||||
}
|
||||
|
||||
get [kConnectTimeoutError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kHeadersTimeoutError = Symbol.for('undici.error.UND_ERR_HEADERS_TIMEOUT')
|
||||
class HeadersTimeoutError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'HeadersTimeoutError'
|
||||
this.message = message || 'Headers Timeout Error'
|
||||
this.code = 'UND_ERR_HEADERS_TIMEOUT'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kHeadersTimeoutError] === true
|
||||
}
|
||||
|
||||
get [kHeadersTimeoutError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kHeadersOverflowError = Symbol.for('undici.error.UND_ERR_HEADERS_OVERFLOW')
|
||||
class HeadersOverflowError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'HeadersOverflowError'
|
||||
this.message = message || 'Headers Overflow Error'
|
||||
this.code = 'UND_ERR_HEADERS_OVERFLOW'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kHeadersOverflowError] === true
|
||||
}
|
||||
|
||||
get [kHeadersOverflowError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kBodyTimeoutError = Symbol.for('undici.error.UND_ERR_BODY_TIMEOUT')
|
||||
class BodyTimeoutError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'BodyTimeoutError'
|
||||
this.message = message || 'Body Timeout Error'
|
||||
this.code = 'UND_ERR_BODY_TIMEOUT'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kBodyTimeoutError] === true
|
||||
}
|
||||
|
||||
get [kBodyTimeoutError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kInvalidArgumentError = Symbol.for('undici.error.UND_ERR_INVALID_ARG')
|
||||
class InvalidArgumentError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'InvalidArgumentError'
|
||||
this.message = message || 'Invalid Argument Error'
|
||||
this.code = 'UND_ERR_INVALID_ARG'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kInvalidArgumentError] === true
|
||||
}
|
||||
|
||||
get [kInvalidArgumentError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kInvalidReturnValueError = Symbol.for('undici.error.UND_ERR_INVALID_RETURN_VALUE')
|
||||
class InvalidReturnValueError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'InvalidReturnValueError'
|
||||
this.message = message || 'Invalid Return Value Error'
|
||||
this.code = 'UND_ERR_INVALID_RETURN_VALUE'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kInvalidReturnValueError] === true
|
||||
}
|
||||
|
||||
get [kInvalidReturnValueError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kAbortError = Symbol.for('undici.error.UND_ERR_ABORT')
|
||||
class AbortError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'AbortError'
|
||||
this.message = message || 'The operation was aborted'
|
||||
this.code = 'UND_ERR_ABORT'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kAbortError] === true
|
||||
}
|
||||
|
||||
get [kAbortError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kRequestAbortedError = Symbol.for('undici.error.UND_ERR_ABORTED')
|
||||
class RequestAbortedError extends AbortError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'AbortError'
|
||||
this.message = message || 'Request aborted'
|
||||
this.code = 'UND_ERR_ABORTED'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kRequestAbortedError] === true
|
||||
}
|
||||
|
||||
get [kRequestAbortedError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kInformationalError = Symbol.for('undici.error.UND_ERR_INFO')
|
||||
class InformationalError extends UndiciError {
|
||||
constructor (message, options) {
|
||||
super(message, options)
|
||||
this.name = 'InformationalError'
|
||||
this.message = message || 'Request information'
|
||||
this.code = 'UND_ERR_INFO'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kInformationalError] === true
|
||||
}
|
||||
|
||||
get [kInformationalError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kRequestContentLengthMismatchError = Symbol.for('undici.error.UND_ERR_REQ_CONTENT_LENGTH_MISMATCH')
|
||||
class RequestContentLengthMismatchError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'RequestContentLengthMismatchError'
|
||||
this.message = message || 'Request body length does not match content-length header'
|
||||
this.code = 'UND_ERR_REQ_CONTENT_LENGTH_MISMATCH'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kRequestContentLengthMismatchError] === true
|
||||
}
|
||||
|
||||
get [kRequestContentLengthMismatchError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kResponseContentLengthMismatchError = Symbol.for('undici.error.UND_ERR_RES_CONTENT_LENGTH_MISMATCH')
|
||||
class ResponseContentLengthMismatchError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'ResponseContentLengthMismatchError'
|
||||
this.message = message || 'Response body length does not match content-length header'
|
||||
this.code = 'UND_ERR_RES_CONTENT_LENGTH_MISMATCH'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kResponseContentLengthMismatchError] === true
|
||||
}
|
||||
|
||||
get [kResponseContentLengthMismatchError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kClientDestroyedError = Symbol.for('undici.error.UND_ERR_DESTROYED')
|
||||
class ClientDestroyedError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'ClientDestroyedError'
|
||||
this.message = message || 'The client is destroyed'
|
||||
this.code = 'UND_ERR_DESTROYED'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kClientDestroyedError] === true
|
||||
}
|
||||
|
||||
get [kClientDestroyedError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kClientClosedError = Symbol.for('undici.error.UND_ERR_CLOSED')
|
||||
class ClientClosedError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'ClientClosedError'
|
||||
this.message = message || 'The client is closed'
|
||||
this.code = 'UND_ERR_CLOSED'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kClientClosedError] === true
|
||||
}
|
||||
|
||||
get [kClientClosedError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kSocketError = Symbol.for('undici.error.UND_ERR_SOCKET')
|
||||
class SocketError extends UndiciError {
|
||||
constructor (message, socket) {
|
||||
super(message)
|
||||
this.name = 'SocketError'
|
||||
this.message = message || 'Socket error'
|
||||
this.code = 'UND_ERR_SOCKET'
|
||||
this.socket = socket
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kSocketError] === true
|
||||
}
|
||||
|
||||
get [kSocketError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kNotSupportedError = Symbol.for('undici.error.UND_ERR_NOT_SUPPORTED')
|
||||
class NotSupportedError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'NotSupportedError'
|
||||
this.message = message || 'Not supported error'
|
||||
this.code = 'UND_ERR_NOT_SUPPORTED'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kNotSupportedError] === true
|
||||
}
|
||||
|
||||
get [kNotSupportedError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kBalancedPoolMissingUpstreamError = Symbol.for('undici.error.UND_ERR_BPL_MISSING_UPSTREAM')
|
||||
class BalancedPoolMissingUpstreamError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'MissingUpstreamError'
|
||||
this.message = message || 'No upstream has been added to the BalancedPool'
|
||||
this.code = 'UND_ERR_BPL_MISSING_UPSTREAM'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kBalancedPoolMissingUpstreamError] === true
|
||||
}
|
||||
|
||||
get [kBalancedPoolMissingUpstreamError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kHTTPParserError = Symbol.for('undici.error.UND_ERR_HTTP_PARSER')
|
||||
class HTTPParserError extends Error {
|
||||
constructor (message, code, data) {
|
||||
super(message)
|
||||
this.name = 'HTTPParserError'
|
||||
this.code = code ? `HPE_${code}` : undefined
|
||||
this.data = data ? data.toString() : undefined
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kHTTPParserError] === true
|
||||
}
|
||||
|
||||
get [kHTTPParserError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kResponseExceededMaxSizeError = Symbol.for('undici.error.UND_ERR_RES_EXCEEDED_MAX_SIZE')
|
||||
class ResponseExceededMaxSizeError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'ResponseExceededMaxSizeError'
|
||||
this.message = message || 'Response content exceeded max size'
|
||||
this.code = 'UND_ERR_RES_EXCEEDED_MAX_SIZE'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kResponseExceededMaxSizeError] === true
|
||||
}
|
||||
|
||||
get [kResponseExceededMaxSizeError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kRequestRetryError = Symbol.for('undici.error.UND_ERR_REQ_RETRY')
|
||||
class RequestRetryError extends UndiciError {
|
||||
constructor (message, code, { headers, data }) {
|
||||
super(message)
|
||||
this.name = 'RequestRetryError'
|
||||
this.message = message || 'Request retry error'
|
||||
this.code = 'UND_ERR_REQ_RETRY'
|
||||
this.statusCode = code
|
||||
this.data = data
|
||||
this.headers = headers
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kRequestRetryError] === true
|
||||
}
|
||||
|
||||
get [kRequestRetryError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kResponseError = Symbol.for('undici.error.UND_ERR_RESPONSE')
|
||||
class ResponseError extends UndiciError {
|
||||
constructor (message, code, { headers, body }) {
|
||||
super(message)
|
||||
this.name = 'ResponseError'
|
||||
this.message = message || 'Response error'
|
||||
this.code = 'UND_ERR_RESPONSE'
|
||||
this.statusCode = code
|
||||
this.body = body
|
||||
this.headers = headers
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kResponseError] === true
|
||||
}
|
||||
|
||||
get [kResponseError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kSecureProxyConnectionError = Symbol.for('undici.error.UND_ERR_PRX_TLS')
|
||||
class SecureProxyConnectionError extends UndiciError {
|
||||
constructor (cause, message, options = {}) {
|
||||
super(message, { cause, ...options })
|
||||
this.name = 'SecureProxyConnectionError'
|
||||
this.message = message || 'Secure Proxy Connection failed'
|
||||
this.code = 'UND_ERR_PRX_TLS'
|
||||
this.cause = cause
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kSecureProxyConnectionError] === true
|
||||
}
|
||||
|
||||
get [kSecureProxyConnectionError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kProxyConnectionError = Symbol.for('undici.error.UND_ERR_PRX_CONN')
|
||||
class ProxyConnectionError extends UndiciError {
|
||||
constructor (cause, message, options = {}) {
|
||||
super(message, { cause, ...options })
|
||||
this.name = 'ProxyConnectionError'
|
||||
this.message = message || 'Proxy Connection failed'
|
||||
this.code = 'UND_ERR_PRX_CONN'
|
||||
this.cause = cause
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kProxyConnectionError] === true
|
||||
}
|
||||
|
||||
get [kProxyConnectionError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const kMaxOriginsReachedError = Symbol.for('undici.error.UND_ERR_MAX_ORIGINS_REACHED')
|
||||
class MaxOriginsReachedError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'MaxOriginsReachedError'
|
||||
this.message = message || 'Maximum allowed origins reached'
|
||||
this.code = 'UND_ERR_MAX_ORIGINS_REACHED'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kMaxOriginsReachedError] === true
|
||||
}
|
||||
|
||||
get [kMaxOriginsReachedError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
class Socks5ProxyError extends UndiciError {
|
||||
constructor (message, code) {
|
||||
super(message)
|
||||
this.name = 'Socks5ProxyError'
|
||||
this.message = message || 'SOCKS5 proxy error'
|
||||
this.code = code || 'UND_ERR_SOCKS5'
|
||||
}
|
||||
}
|
||||
|
||||
const kMessageSizeExceededError = Symbol.for('undici.error.UND_ERR_WS_MESSAGE_SIZE_EXCEEDED')
|
||||
class MessageSizeExceededError extends UndiciError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'MessageSizeExceededError'
|
||||
this.message = message || 'Max decompressed message size exceeded'
|
||||
this.code = 'UND_ERR_WS_MESSAGE_SIZE_EXCEEDED'
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance] (instance) {
|
||||
return instance && instance[kMessageSizeExceededError] === true
|
||||
}
|
||||
|
||||
get [kMessageSizeExceededError] () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AbortError,
|
||||
HTTPParserError,
|
||||
UndiciError,
|
||||
HeadersTimeoutError,
|
||||
HeadersOverflowError,
|
||||
BodyTimeoutError,
|
||||
RequestContentLengthMismatchError,
|
||||
ConnectTimeoutError,
|
||||
InvalidArgumentError,
|
||||
InvalidReturnValueError,
|
||||
RequestAbortedError,
|
||||
ClientDestroyedError,
|
||||
ClientClosedError,
|
||||
InformationalError,
|
||||
SocketError,
|
||||
NotSupportedError,
|
||||
ResponseContentLengthMismatchError,
|
||||
BalancedPoolMissingUpstreamError,
|
||||
ResponseExceededMaxSizeError,
|
||||
RequestRetryError,
|
||||
ResponseError,
|
||||
SecureProxyConnectionError,
|
||||
ProxyConnectionError,
|
||||
MaxOriginsReachedError,
|
||||
Socks5ProxyError,
|
||||
MessageSizeExceededError
|
||||
}
|
||||
+567
@@ -0,0 +1,567 @@
|
||||
'use strict'
|
||||
|
||||
const {
|
||||
InvalidArgumentError,
|
||||
NotSupportedError
|
||||
} = require('./errors')
|
||||
const assert = require('node:assert')
|
||||
const {
|
||||
isValidHTTPToken,
|
||||
isValidHeaderValue,
|
||||
isStream,
|
||||
destroy,
|
||||
isBuffer,
|
||||
isFormDataLike,
|
||||
isIterable,
|
||||
hasSafeIterator,
|
||||
isBlobLike,
|
||||
serializePathWithQuery,
|
||||
parseHeaders,
|
||||
assertRequestHandler,
|
||||
getServerName,
|
||||
normalizedMethodRecords,
|
||||
getProtocolFromUrlString
|
||||
} = require('./util')
|
||||
const { channels } = require('./diagnostics.js')
|
||||
const { headerNameLowerCasedRecord } = require('./constants')
|
||||
|
||||
// Verifies that a given path is valid does not contain control chars \x00 to \x20
|
||||
const invalidPathRegex = /[^\u0021-\u00ff]/
|
||||
|
||||
function isValidContentLengthHeaderValue (val) {
|
||||
if (typeof val !== 'string' || val.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let i = 0; i < val.length; i++) {
|
||||
const charCode = val.charCodeAt(i)
|
||||
if (charCode < 48 || charCode > 57) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const kHandler = Symbol('handler')
|
||||
const kController = Symbol('controller')
|
||||
const kResume = Symbol('resume')
|
||||
|
||||
class RequestController {
|
||||
#paused = false
|
||||
#reason = null
|
||||
#aborted = false
|
||||
#abort
|
||||
|
||||
[kResume] = null
|
||||
|
||||
rawHeaders = null
|
||||
rawTrailers = null
|
||||
|
||||
constructor (abort) {
|
||||
this.#abort = abort
|
||||
}
|
||||
|
||||
pause () {
|
||||
this.#paused = true
|
||||
}
|
||||
|
||||
resume () {
|
||||
if (this.#paused) {
|
||||
this.#paused = false
|
||||
this[kResume]?.()
|
||||
}
|
||||
}
|
||||
|
||||
abort (reason) {
|
||||
if (!this.#aborted) {
|
||||
this.#aborted = true
|
||||
this.#reason = reason
|
||||
this.#abort(reason)
|
||||
}
|
||||
}
|
||||
|
||||
get aborted () {
|
||||
return this.#aborted
|
||||
}
|
||||
|
||||
get reason () {
|
||||
return this.#reason
|
||||
}
|
||||
|
||||
get paused () {
|
||||
return this.#paused
|
||||
}
|
||||
}
|
||||
|
||||
class Request {
|
||||
constructor (origin, {
|
||||
path,
|
||||
method,
|
||||
body,
|
||||
headers,
|
||||
query,
|
||||
idempotent,
|
||||
blocking,
|
||||
upgrade,
|
||||
headersTimeout,
|
||||
bodyTimeout,
|
||||
reset,
|
||||
expectContinue,
|
||||
servername,
|
||||
throwOnError,
|
||||
maxRedirections,
|
||||
typeOfService
|
||||
}, handler) {
|
||||
if (typeof path !== 'string') {
|
||||
throw new InvalidArgumentError('path must be a string')
|
||||
} else if (
|
||||
path[0] !== '/' &&
|
||||
!(path.startsWith('http://') || path.startsWith('https://')) &&
|
||||
method !== 'CONNECT'
|
||||
) {
|
||||
throw new InvalidArgumentError('path must be an absolute URL or start with a slash')
|
||||
} else if (invalidPathRegex.test(path)) {
|
||||
throw new InvalidArgumentError('invalid request path')
|
||||
}
|
||||
|
||||
if (typeof method !== 'string') {
|
||||
throw new InvalidArgumentError('method must be a string')
|
||||
} else if (normalizedMethodRecords[method] === undefined && !isValidHTTPToken(method)) {
|
||||
throw new InvalidArgumentError('invalid request method')
|
||||
}
|
||||
|
||||
if (upgrade && typeof upgrade !== 'string') {
|
||||
throw new InvalidArgumentError('upgrade must be a string')
|
||||
}
|
||||
|
||||
if (upgrade && !isValidHeaderValue(upgrade)) {
|
||||
throw new InvalidArgumentError('invalid upgrade header')
|
||||
}
|
||||
|
||||
if (headersTimeout != null && (!Number.isFinite(headersTimeout) || headersTimeout < 0)) {
|
||||
throw new InvalidArgumentError('invalid headersTimeout')
|
||||
}
|
||||
|
||||
if (bodyTimeout != null && (!Number.isFinite(bodyTimeout) || bodyTimeout < 0)) {
|
||||
throw new InvalidArgumentError('invalid bodyTimeout')
|
||||
}
|
||||
|
||||
if (reset != null && typeof reset !== 'boolean') {
|
||||
throw new InvalidArgumentError('invalid reset')
|
||||
}
|
||||
|
||||
if (expectContinue != null && typeof expectContinue !== 'boolean') {
|
||||
throw new InvalidArgumentError('invalid expectContinue')
|
||||
}
|
||||
|
||||
if (throwOnError != null) {
|
||||
throw new InvalidArgumentError('invalid throwOnError')
|
||||
}
|
||||
|
||||
if (maxRedirections != null && maxRedirections !== 0) {
|
||||
throw new InvalidArgumentError('maxRedirections is not supported, use the redirect interceptor')
|
||||
}
|
||||
|
||||
if (typeOfService != null && (!Number.isInteger(typeOfService) || typeOfService < 0 || typeOfService > 255)) {
|
||||
throw new InvalidArgumentError('typeOfService must be an integer between 0 and 255')
|
||||
}
|
||||
|
||||
this.headersTimeout = headersTimeout
|
||||
|
||||
this.bodyTimeout = bodyTimeout
|
||||
|
||||
this.method = method
|
||||
|
||||
this.typeOfService = typeOfService
|
||||
|
||||
this.abort = null
|
||||
|
||||
if (body == null) {
|
||||
this.body = null
|
||||
} else if (isStream(body)) {
|
||||
this.body = body
|
||||
|
||||
const rState = this.body._readableState
|
||||
if (!rState || !rState.autoDestroy) {
|
||||
this.endHandler = function autoDestroy () {
|
||||
destroy(this)
|
||||
}
|
||||
this.body.on('end', this.endHandler)
|
||||
}
|
||||
|
||||
this.errorHandler = err => {
|
||||
if (this.abort) {
|
||||
this.abort(err)
|
||||
} else {
|
||||
this.error = err
|
||||
}
|
||||
}
|
||||
this.body.on('error', this.errorHandler)
|
||||
} else if (isBuffer(body)) {
|
||||
this.body = body.byteLength ? body : null
|
||||
} else if (ArrayBuffer.isView(body)) {
|
||||
this.body = body.buffer.byteLength ? Buffer.from(body.buffer, body.byteOffset, body.byteLength) : null
|
||||
} else if (body instanceof ArrayBuffer) {
|
||||
this.body = body.byteLength ? Buffer.from(body) : null
|
||||
} else if (typeof body === 'string') {
|
||||
this.body = body.length ? Buffer.from(body) : null
|
||||
} else if (isFormDataLike(body) || isIterable(body) || isBlobLike(body)) {
|
||||
this.body = body
|
||||
} else {
|
||||
throw new InvalidArgumentError('body must be a string, a Buffer, a Readable stream, an iterable, or an async iterable')
|
||||
}
|
||||
|
||||
this.completed = false
|
||||
this.aborted = false
|
||||
|
||||
this.upgrade = upgrade || null
|
||||
|
||||
this.path = query ? serializePathWithQuery(path, query) : path
|
||||
|
||||
// TODO: shall we maybe standardize it to an URL object?
|
||||
this.origin = origin
|
||||
|
||||
this.protocol = getProtocolFromUrlString(origin)
|
||||
|
||||
this.idempotent = idempotent == null
|
||||
? method === 'HEAD' || method === 'GET' || method === 'QUERY'
|
||||
: idempotent
|
||||
|
||||
this.blocking = blocking ?? this.method !== 'HEAD'
|
||||
|
||||
this.reset = reset == null ? null : reset
|
||||
|
||||
this.host = null
|
||||
|
||||
this.contentLength = null
|
||||
|
||||
this.contentType = null
|
||||
|
||||
this.headers = []
|
||||
|
||||
// Only for H2
|
||||
this.expectContinue = expectContinue != null ? expectContinue : false
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
if (headers.length % 2 !== 0) {
|
||||
throw new InvalidArgumentError('headers array must be even')
|
||||
}
|
||||
for (let i = 0; i < headers.length; i += 2) {
|
||||
processHeader(this, headers[i], headers[i + 1])
|
||||
}
|
||||
} else if (headers && typeof headers === 'object') {
|
||||
if (hasSafeIterator(headers)) {
|
||||
for (const header of headers) {
|
||||
if (!Array.isArray(header) || header.length !== 2) {
|
||||
throw new InvalidArgumentError('headers must be in key-value pair format')
|
||||
}
|
||||
processHeader(this, header[0], header[1])
|
||||
}
|
||||
} else {
|
||||
const keys = Object.keys(headers)
|
||||
for (let i = 0; i < keys.length; ++i) {
|
||||
processHeader(this, keys[i], headers[keys[i]])
|
||||
}
|
||||
}
|
||||
} else if (headers != null) {
|
||||
throw new InvalidArgumentError('headers must be an object or an array')
|
||||
}
|
||||
|
||||
assertRequestHandler(handler, method, upgrade)
|
||||
|
||||
this.servername = servername || getServerName(this.host) || null
|
||||
|
||||
this[kHandler] = handler
|
||||
|
||||
if (channels.create.hasSubscribers) {
|
||||
channels.create.publish({ request: this })
|
||||
}
|
||||
}
|
||||
|
||||
onBodySent (chunk) {
|
||||
if (channels.bodyChunkSent.hasSubscribers) {
|
||||
channels.bodyChunkSent.publish({ request: this, chunk })
|
||||
}
|
||||
if (this[kHandler].onBodySent) {
|
||||
try {
|
||||
return this[kHandler].onBodySent(chunk)
|
||||
} catch (err) {
|
||||
this.abort(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onRequestSent () {
|
||||
if (channels.bodySent.hasSubscribers) {
|
||||
channels.bodySent.publish({ request: this })
|
||||
}
|
||||
|
||||
if (this[kHandler].onRequestSent) {
|
||||
try {
|
||||
return this[kHandler].onRequestSent()
|
||||
} catch (err) {
|
||||
this.abort(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onRequestStart (abort, context) {
|
||||
assert(!this.aborted)
|
||||
assert(!this.completed)
|
||||
|
||||
this[kController] = new RequestController(abort)
|
||||
|
||||
if (this.error) {
|
||||
this[kController].abort(this.error)
|
||||
return
|
||||
}
|
||||
|
||||
this.abort = abort
|
||||
return this[kHandler].onRequestStart(this[kController], context)
|
||||
}
|
||||
|
||||
onResponseStarted () {
|
||||
return this[kHandler].onResponseStarted?.()
|
||||
}
|
||||
|
||||
onResponseStart (statusCode, headers, resume, statusText) {
|
||||
assert(!this.aborted)
|
||||
assert(!this.completed)
|
||||
|
||||
if (channels.headers.hasSubscribers) {
|
||||
channels.headers.publish({ request: this, response: { statusCode, headers, statusText } })
|
||||
}
|
||||
|
||||
const controller = this[kController]
|
||||
if (controller) {
|
||||
controller[kResume] = resume
|
||||
controller.rawHeaders = headers
|
||||
}
|
||||
|
||||
const parsedHeaders = Array.isArray(headers) ? parseHeaders(headers) : headers
|
||||
|
||||
try {
|
||||
this[kHandler].onResponseStart?.(controller, statusCode, parsedHeaders, statusText)
|
||||
return !controller?.paused
|
||||
} catch (err) {
|
||||
this.abort(err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
onResponseData (chunk) {
|
||||
assert(!this.aborted)
|
||||
assert(!this.completed)
|
||||
|
||||
if (channels.bodyChunkReceived.hasSubscribers) {
|
||||
channels.bodyChunkReceived.publish({ request: this, chunk })
|
||||
}
|
||||
|
||||
const controller = this[kController]
|
||||
try {
|
||||
this[kHandler].onResponseData?.(controller, chunk)
|
||||
return !controller?.paused
|
||||
} catch (err) {
|
||||
this.abort(err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} statusCode
|
||||
* @param {Buffer[]|string[]|import('../../types/header.d.ts').IncomingHttpHeaders} headers
|
||||
* @param {import('node:stream').Duplex} socket
|
||||
* @param {string} [statusText]
|
||||
*/
|
||||
onRequestUpgrade (statusCode, headers, socket, statusText = '') {
|
||||
this.onFinally()
|
||||
|
||||
assert(!this.aborted)
|
||||
assert(!this.completed)
|
||||
|
||||
if (channels.headers.hasSubscribers) {
|
||||
channels.headers.publish({ request: this, response: { statusCode, headers, statusText } })
|
||||
}
|
||||
|
||||
const controller = this[kController]
|
||||
if (controller) {
|
||||
controller.rawHeaders = headers
|
||||
}
|
||||
|
||||
const parsedHeaders = Array.isArray(headers) ? parseHeaders(headers) : headers
|
||||
|
||||
const result = this[kHandler].onRequestUpgrade?.(controller, statusCode, parsedHeaders, socket)
|
||||
|
||||
if (!this.aborted) {
|
||||
this.completed = true
|
||||
if (channels.trailers.hasSubscribers) {
|
||||
channels.trailers.publish({ request: this, trailers: [] })
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
onResponseEnd (trailers) {
|
||||
this.onFinally()
|
||||
|
||||
assert(!this.aborted)
|
||||
assert(!this.completed)
|
||||
|
||||
this.completed = true
|
||||
if (channels.trailers.hasSubscribers) {
|
||||
channels.trailers.publish({ request: this, trailers })
|
||||
}
|
||||
|
||||
const controller = this[kController]
|
||||
if (controller) {
|
||||
controller.rawTrailers = trailers
|
||||
}
|
||||
|
||||
const parsedTrailers = Array.isArray(trailers) ? parseHeaders(trailers) : trailers
|
||||
|
||||
try {
|
||||
return this[kHandler].onResponseEnd?.(controller, parsedTrailers)
|
||||
} catch (err) {
|
||||
// TODO (fix): This might be a bad idea?
|
||||
this.onResponseError(err)
|
||||
}
|
||||
}
|
||||
|
||||
onResponseError (error) {
|
||||
this.onFinally()
|
||||
|
||||
if (channels.error.hasSubscribers) {
|
||||
channels.error.publish({ request: this, error })
|
||||
}
|
||||
|
||||
if (this.aborted) {
|
||||
return
|
||||
}
|
||||
this.aborted = true
|
||||
|
||||
const controller = this[kController]
|
||||
|
||||
return this[kHandler].onResponseError?.(controller, error)
|
||||
}
|
||||
|
||||
onFinally () {
|
||||
if (this.errorHandler) {
|
||||
this.body.off('error', this.errorHandler)
|
||||
this.errorHandler = null
|
||||
}
|
||||
|
||||
if (this.endHandler) {
|
||||
this.body.off('end', this.endHandler)
|
||||
this.endHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
addHeader (key, value) {
|
||||
processHeader(this, key, value)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
function processHeader (request, key, val) {
|
||||
if (val && (typeof val === 'object' && !Array.isArray(val))) {
|
||||
throw new InvalidArgumentError(`invalid ${key} header`)
|
||||
} else if (val === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
let headerName = headerNameLowerCasedRecord[key]
|
||||
|
||||
if (headerName === undefined) {
|
||||
headerName = key.toLowerCase()
|
||||
if (headerNameLowerCasedRecord[headerName] === undefined && !isValidHTTPToken(headerName)) {
|
||||
throw new InvalidArgumentError('invalid header key')
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(val)) {
|
||||
const arr = []
|
||||
for (let i = 0; i < val.length; i++) {
|
||||
if (typeof val[i] === 'string') {
|
||||
if (!isValidHeaderValue(val[i])) {
|
||||
throw new InvalidArgumentError(`invalid ${key} header`)
|
||||
}
|
||||
arr.push(val[i])
|
||||
} else if (val[i] === null) {
|
||||
arr.push('')
|
||||
} else if (typeof val[i] === 'object') {
|
||||
throw new InvalidArgumentError(`invalid ${key} header`)
|
||||
} else {
|
||||
// Coerce primitives (and reject unsafe coercions such as functions
|
||||
// with a crafted toString/Symbol.toPrimitive).
|
||||
const str = `${val[i]}`
|
||||
if (!isValidHeaderValue(str)) {
|
||||
throw new InvalidArgumentError(`invalid ${key} header`)
|
||||
}
|
||||
arr.push(str)
|
||||
}
|
||||
}
|
||||
val = arr
|
||||
} else if (typeof val === 'string') {
|
||||
if (!isValidHeaderValue(val)) {
|
||||
throw new InvalidArgumentError(`invalid ${key} header`)
|
||||
}
|
||||
} else if (val === null) {
|
||||
val = ''
|
||||
} else {
|
||||
// Coerce primitives (and reject unsafe coercions such as functions
|
||||
// with a crafted toString/Symbol.toPrimitive).
|
||||
val = `${val}`
|
||||
if (!isValidHeaderValue(val)) {
|
||||
throw new InvalidArgumentError(`invalid ${key} header`)
|
||||
}
|
||||
}
|
||||
|
||||
if (headerName === 'host') {
|
||||
if (request.host !== null) {
|
||||
throw new InvalidArgumentError('duplicate host header')
|
||||
}
|
||||
if (typeof val !== 'string') {
|
||||
throw new InvalidArgumentError('invalid host header')
|
||||
}
|
||||
// Consumed by Client
|
||||
request.host = val
|
||||
} else if (headerName === 'content-length') {
|
||||
if (request.contentLength !== null) {
|
||||
throw new InvalidArgumentError('duplicate content-length header')
|
||||
}
|
||||
if (!isValidContentLengthHeaderValue(val)) {
|
||||
throw new InvalidArgumentError('invalid content-length header')
|
||||
}
|
||||
request.contentLength = parseInt(val, 10)
|
||||
} else if (request.contentType === null && headerName === 'content-type') {
|
||||
request.contentType = val
|
||||
request.headers.push(key, val)
|
||||
} else if (headerName === 'transfer-encoding' || headerName === 'keep-alive' || headerName === 'upgrade') {
|
||||
throw new InvalidArgumentError(`invalid ${headerName} header`)
|
||||
} else if (headerName === 'connection') {
|
||||
// Per RFC 7230 Section 6.1, Connection header can contain
|
||||
// a comma-separated list of connection option tokens (header names)
|
||||
const value = typeof val === 'string' ? val : null
|
||||
if (value === null) {
|
||||
throw new InvalidArgumentError('invalid connection header')
|
||||
}
|
||||
|
||||
for (const token of value.toLowerCase().split(',')) {
|
||||
const trimmed = token.trim()
|
||||
if (!isValidHTTPToken(trimmed)) {
|
||||
throw new InvalidArgumentError('invalid connection header')
|
||||
}
|
||||
if (trimmed === 'close') {
|
||||
request.reset = true
|
||||
}
|
||||
}
|
||||
} else if (headerName === 'expect') {
|
||||
throw new NotSupportedError('expect header not supported')
|
||||
} else {
|
||||
request.headers.push(key, val)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Request
|
||||
+422
@@ -0,0 +1,422 @@
|
||||
'use strict'
|
||||
|
||||
const { EventEmitter } = require('node:events')
|
||||
const { Buffer } = require('node:buffer')
|
||||
const { InvalidArgumentError, Socks5ProxyError } = require('./errors')
|
||||
const { debuglog } = require('node:util')
|
||||
const { parseAddress } = require('./socks5-utils')
|
||||
|
||||
const debug = debuglog('undici:socks5')
|
||||
const EMPTY_BUFFER = Buffer.alloc(0)
|
||||
|
||||
// SOCKS5 constants
|
||||
const SOCKS_VERSION = 0x05
|
||||
|
||||
// Authentication methods
|
||||
const AUTH_METHODS = {
|
||||
NO_AUTH: 0x00,
|
||||
GSSAPI: 0x01,
|
||||
USERNAME_PASSWORD: 0x02,
|
||||
NO_ACCEPTABLE: 0xFF
|
||||
}
|
||||
|
||||
// SOCKS5 commands
|
||||
const COMMANDS = {
|
||||
CONNECT: 0x01,
|
||||
BIND: 0x02,
|
||||
UDP_ASSOCIATE: 0x03
|
||||
}
|
||||
|
||||
// Address types
|
||||
const ADDRESS_TYPES = {
|
||||
IPV4: 0x01,
|
||||
DOMAIN: 0x03,
|
||||
IPV6: 0x04
|
||||
}
|
||||
|
||||
// Reply codes
|
||||
const REPLY_CODES = {
|
||||
SUCCEEDED: 0x00,
|
||||
GENERAL_FAILURE: 0x01,
|
||||
CONNECTION_NOT_ALLOWED: 0x02,
|
||||
NETWORK_UNREACHABLE: 0x03,
|
||||
HOST_UNREACHABLE: 0x04,
|
||||
CONNECTION_REFUSED: 0x05,
|
||||
TTL_EXPIRED: 0x06,
|
||||
COMMAND_NOT_SUPPORTED: 0x07,
|
||||
ADDRESS_TYPE_NOT_SUPPORTED: 0x08
|
||||
}
|
||||
|
||||
// State machine states
|
||||
const STATES = {
|
||||
INITIAL: 'initial',
|
||||
HANDSHAKING: 'handshaking',
|
||||
AUTHENTICATING: 'authenticating',
|
||||
AUTHENTICATED: 'authenticated',
|
||||
CONNECTING: 'connecting',
|
||||
CONNECTED: 'connected',
|
||||
ERROR: 'error',
|
||||
CLOSED: 'closed'
|
||||
}
|
||||
|
||||
/**
|
||||
* SOCKS5 client implementation
|
||||
* Handles SOCKS5 protocol negotiation and connection establishment
|
||||
*/
|
||||
class Socks5Client extends EventEmitter {
|
||||
constructor (socket, options = {}) {
|
||||
super()
|
||||
|
||||
if (!socket) {
|
||||
throw new InvalidArgumentError('socket is required')
|
||||
}
|
||||
|
||||
this.socket = socket
|
||||
this.options = options
|
||||
this.state = STATES.INITIAL
|
||||
this.buffer = EMPTY_BUFFER
|
||||
this.onSocketData = this.onData.bind(this)
|
||||
this.onSocketError = this.onError.bind(this)
|
||||
this.onSocketClose = this.onClose.bind(this)
|
||||
|
||||
// Authentication settings
|
||||
this.authMethods = []
|
||||
if (options.username && options.password) {
|
||||
this.authMethods.push(AUTH_METHODS.USERNAME_PASSWORD)
|
||||
}
|
||||
this.authMethods.push(AUTH_METHODS.NO_AUTH)
|
||||
|
||||
// Socket event handlers
|
||||
this.socket.on('data', this.onSocketData)
|
||||
this.socket.on('error', this.onSocketError)
|
||||
this.socket.on('close', this.onSocketClose)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming data from the socket
|
||||
*/
|
||||
onData (data) {
|
||||
debug('received data', data.length, 'bytes in state', this.state)
|
||||
this.buffer = Buffer.concat([this.buffer, data])
|
||||
|
||||
try {
|
||||
switch (this.state) {
|
||||
case STATES.HANDSHAKING:
|
||||
this.handleHandshakeResponse()
|
||||
break
|
||||
case STATES.AUTHENTICATING:
|
||||
this.handleAuthResponse()
|
||||
break
|
||||
case STATES.CONNECTING:
|
||||
this.handleConnectResponse()
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
this.onError(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle socket errors
|
||||
*/
|
||||
onError (err) {
|
||||
debug('socket error', err)
|
||||
this.state = STATES.ERROR
|
||||
this.emit('error', err)
|
||||
this.destroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle socket close
|
||||
*/
|
||||
onClose () {
|
||||
debug('socket closed')
|
||||
this.state = STATES.CLOSED
|
||||
this.emit('close')
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the client and underlying socket
|
||||
*/
|
||||
destroy () {
|
||||
if (this.socket && !this.socket.destroyed) {
|
||||
this.socket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
markAuthenticated () {
|
||||
this.state = STATES.AUTHENTICATED
|
||||
this.emit('authenticated')
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the SOCKS5 handshake
|
||||
*/
|
||||
handshake () {
|
||||
if (this.state !== STATES.INITIAL) {
|
||||
throw new InvalidArgumentError('Handshake already started')
|
||||
}
|
||||
|
||||
debug('starting handshake with', this.authMethods.length, 'auth methods')
|
||||
this.state = STATES.HANDSHAKING
|
||||
|
||||
// Build handshake request
|
||||
// +----+----------+----------+
|
||||
// |VER | NMETHODS | METHODS |
|
||||
// +----+----------+----------+
|
||||
// | 1 | 1 | 1 to 255 |
|
||||
// +----+----------+----------+
|
||||
const request = Buffer.alloc(2 + this.authMethods.length)
|
||||
request[0] = SOCKS_VERSION
|
||||
request[1] = this.authMethods.length
|
||||
this.authMethods.forEach((method, i) => {
|
||||
request[2 + i] = method
|
||||
})
|
||||
|
||||
this.socket.write(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle handshake response from server
|
||||
*/
|
||||
handleHandshakeResponse () {
|
||||
if (this.buffer.length < 2) {
|
||||
return // Not enough data yet
|
||||
}
|
||||
|
||||
const version = this.buffer[0]
|
||||
const method = this.buffer[1]
|
||||
|
||||
if (version !== SOCKS_VERSION) {
|
||||
throw new Socks5ProxyError(`Invalid SOCKS version: ${version}`, 'UND_ERR_SOCKS5_VERSION')
|
||||
}
|
||||
|
||||
if (method === AUTH_METHODS.NO_ACCEPTABLE) {
|
||||
throw new Socks5ProxyError('No acceptable authentication method', 'UND_ERR_SOCKS5_AUTH_REJECTED')
|
||||
}
|
||||
|
||||
this.buffer = this.buffer.subarray(2)
|
||||
debug('server selected auth method', method)
|
||||
|
||||
if (method === AUTH_METHODS.NO_AUTH) {
|
||||
this.markAuthenticated()
|
||||
} else if (method === AUTH_METHODS.USERNAME_PASSWORD) {
|
||||
this.state = STATES.AUTHENTICATING
|
||||
this.sendAuthRequest()
|
||||
} else {
|
||||
throw new Socks5ProxyError(`Unsupported authentication method: ${method}`, 'UND_ERR_SOCKS5_AUTH_METHOD')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send username/password authentication request
|
||||
*/
|
||||
sendAuthRequest () {
|
||||
const { username, password } = this.options
|
||||
|
||||
if (!username || !password) {
|
||||
throw new InvalidArgumentError('Username and password required for authentication')
|
||||
}
|
||||
|
||||
debug('sending username/password auth')
|
||||
|
||||
// Username/Password authentication request (RFC 1929)
|
||||
// +----+------+----------+------+----------+
|
||||
// |VER | ULEN | UNAME | PLEN | PASSWD |
|
||||
// +----+------+----------+------+----------+
|
||||
// | 1 | 1 | 1 to 255 | 1 | 1 to 255 |
|
||||
// +----+------+----------+------+----------+
|
||||
const usernameBuffer = Buffer.from(username)
|
||||
const passwordBuffer = Buffer.from(password)
|
||||
|
||||
if (usernameBuffer.length > 255 || passwordBuffer.length > 255) {
|
||||
throw new InvalidArgumentError('Username or password too long')
|
||||
}
|
||||
|
||||
const request = Buffer.alloc(3 + usernameBuffer.length + passwordBuffer.length)
|
||||
request[0] = 0x01 // Sub-negotiation version
|
||||
request[1] = usernameBuffer.length
|
||||
usernameBuffer.copy(request, 2)
|
||||
request[2 + usernameBuffer.length] = passwordBuffer.length
|
||||
passwordBuffer.copy(request, 3 + usernameBuffer.length)
|
||||
|
||||
this.socket.write(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication response
|
||||
*/
|
||||
handleAuthResponse () {
|
||||
if (this.buffer.length < 2) {
|
||||
return // Not enough data yet
|
||||
}
|
||||
|
||||
const version = this.buffer[0]
|
||||
const status = this.buffer[1]
|
||||
|
||||
if (version !== 0x01) {
|
||||
throw new Socks5ProxyError(`Invalid auth sub-negotiation version: ${version}`, 'UND_ERR_SOCKS5_AUTH_VERSION')
|
||||
}
|
||||
|
||||
if (status !== 0x00) {
|
||||
throw new Socks5ProxyError('Authentication failed', 'UND_ERR_SOCKS5_AUTH_FAILED')
|
||||
}
|
||||
|
||||
this.buffer = this.buffer.subarray(2)
|
||||
debug('authentication successful')
|
||||
this.markAuthenticated()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send CONNECT command
|
||||
* @param {string} address - Target address (IP or domain)
|
||||
* @param {number} port - Target port
|
||||
*/
|
||||
connect (address, port) {
|
||||
if (this.state === STATES.CONNECTING || this.state === STATES.CONNECTED) {
|
||||
throw new InvalidArgumentError('Connection already in progress')
|
||||
}
|
||||
|
||||
if (this.state !== STATES.AUTHENTICATED) {
|
||||
throw new InvalidArgumentError('Client must be authenticated before CONNECT')
|
||||
}
|
||||
|
||||
debug('connecting to', address, port)
|
||||
this.state = STATES.CONNECTING
|
||||
|
||||
const request = this.buildConnectRequest(COMMANDS.CONNECT, address, port)
|
||||
this.socket.write(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SOCKS5 request
|
||||
*/
|
||||
buildConnectRequest (command, address, port) {
|
||||
// Parse address to determine type and buffer
|
||||
const { type: addressType, buffer: addressBuffer } = parseAddress(address)
|
||||
|
||||
// Build request
|
||||
// +----+-----+-------+------+----------+----------+
|
||||
// |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
|
||||
// +----+-----+-------+------+----------+----------+
|
||||
// | 1 | 1 | X'00' | 1 | Variable | 2 |
|
||||
// +----+-----+-------+------+----------+----------+
|
||||
const request = Buffer.alloc(4 + addressBuffer.length + 2)
|
||||
request[0] = SOCKS_VERSION
|
||||
request[1] = command
|
||||
request[2] = 0x00 // Reserved
|
||||
request[3] = addressType
|
||||
addressBuffer.copy(request, 4)
|
||||
request.writeUInt16BE(port, 4 + addressBuffer.length)
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CONNECT response
|
||||
*/
|
||||
handleConnectResponse () {
|
||||
if (this.buffer.length < 4) {
|
||||
return // Not enough data for header
|
||||
}
|
||||
|
||||
const version = this.buffer[0]
|
||||
const reply = this.buffer[1]
|
||||
const addressType = this.buffer[3]
|
||||
|
||||
if (version !== SOCKS_VERSION) {
|
||||
throw new Socks5ProxyError(`Invalid SOCKS version in reply: ${version}`, 'UND_ERR_SOCKS5_REPLY_VERSION')
|
||||
}
|
||||
|
||||
// Calculate the expected response length
|
||||
let responseLength = 4 // VER + REP + RSV + ATYP
|
||||
if (addressType === ADDRESS_TYPES.IPV4) {
|
||||
responseLength += 4 + 2 // IPv4 + port
|
||||
} else if (addressType === ADDRESS_TYPES.DOMAIN) {
|
||||
if (this.buffer.length < 5) {
|
||||
return // Need domain length byte
|
||||
}
|
||||
responseLength += 1 + this.buffer[4] + 2 // length byte + domain + port
|
||||
} else if (addressType === ADDRESS_TYPES.IPV6) {
|
||||
responseLength += 16 + 2 // IPv6 + port
|
||||
} else {
|
||||
throw new Socks5ProxyError(`Invalid address type in reply: ${addressType}`, 'UND_ERR_SOCKS5_ADDR_TYPE')
|
||||
}
|
||||
|
||||
if (this.buffer.length < responseLength) {
|
||||
return // Not enough data for full response
|
||||
}
|
||||
|
||||
if (reply !== REPLY_CODES.SUCCEEDED) {
|
||||
const errorMessage = this.getReplyErrorMessage(reply)
|
||||
throw new Socks5ProxyError(`SOCKS5 connection failed: ${errorMessage}`, `UND_ERR_SOCKS5_REPLY_${reply}`)
|
||||
}
|
||||
|
||||
// Parse bound address and port
|
||||
let boundAddress
|
||||
let offset = 4
|
||||
|
||||
if (addressType === ADDRESS_TYPES.IPV4) {
|
||||
boundAddress = Array.from(this.buffer.subarray(offset, offset + 4)).join('.')
|
||||
offset += 4
|
||||
} else if (addressType === ADDRESS_TYPES.DOMAIN) {
|
||||
const domainLength = this.buffer[offset]
|
||||
offset += 1
|
||||
boundAddress = this.buffer.subarray(offset, offset + domainLength).toString()
|
||||
offset += domainLength
|
||||
} else if (addressType === ADDRESS_TYPES.IPV6) {
|
||||
// Parse IPv6 address from 16-byte buffer
|
||||
const parts = []
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const value = this.buffer.readUInt16BE(offset + i * 2)
|
||||
parts.push(value.toString(16))
|
||||
}
|
||||
boundAddress = parts.join(':')
|
||||
offset += 16
|
||||
}
|
||||
|
||||
const boundPort = this.buffer.readUInt16BE(offset)
|
||||
|
||||
this.buffer = EMPTY_BUFFER
|
||||
this.state = STATES.CONNECTED
|
||||
this.socket.removeListener('data', this.onSocketData)
|
||||
|
||||
debug('connected, bound address:', boundAddress, 'port:', boundPort)
|
||||
this.emit('connected', { address: boundAddress, port: boundPort })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable error message for reply code
|
||||
*/
|
||||
getReplyErrorMessage (reply) {
|
||||
switch (reply) {
|
||||
case REPLY_CODES.GENERAL_FAILURE:
|
||||
return 'General SOCKS server failure'
|
||||
case REPLY_CODES.CONNECTION_NOT_ALLOWED:
|
||||
return 'Connection not allowed by ruleset'
|
||||
case REPLY_CODES.NETWORK_UNREACHABLE:
|
||||
return 'Network unreachable'
|
||||
case REPLY_CODES.HOST_UNREACHABLE:
|
||||
return 'Host unreachable'
|
||||
case REPLY_CODES.CONNECTION_REFUSED:
|
||||
return 'Connection refused'
|
||||
case REPLY_CODES.TTL_EXPIRED:
|
||||
return 'TTL expired'
|
||||
case REPLY_CODES.COMMAND_NOT_SUPPORTED:
|
||||
return 'Command not supported'
|
||||
case REPLY_CODES.ADDRESS_TYPE_NOT_SUPPORTED:
|
||||
return 'Address type not supported'
|
||||
default:
|
||||
return `Unknown error code: ${reply}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Socks5Client,
|
||||
AUTH_METHODS,
|
||||
COMMANDS,
|
||||
ADDRESS_TYPES,
|
||||
REPLY_CODES,
|
||||
STATES
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
'use strict'
|
||||
|
||||
const { Buffer } = require('node:buffer')
|
||||
const net = require('node:net')
|
||||
const { InvalidArgumentError } = require('./errors')
|
||||
|
||||
/**
|
||||
* Parse an address and determine its type
|
||||
* @param {string} address - The address to parse
|
||||
* @returns {{type: number, buffer: Buffer}} Address type and buffer
|
||||
*/
|
||||
function parseAddress (address) {
|
||||
// Check if it's an IPv4 address
|
||||
if (net.isIPv4(address)) {
|
||||
const parts = address.split('.').map(Number)
|
||||
return {
|
||||
type: 0x01, // IPv4
|
||||
buffer: Buffer.from(parts)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's an IPv6 address
|
||||
if (net.isIPv6(address)) {
|
||||
return {
|
||||
type: 0x04, // IPv6
|
||||
buffer: parseIPv6(address)
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, treat as domain name
|
||||
const domainBuffer = Buffer.from(address, 'utf8')
|
||||
if (domainBuffer.length > 255) {
|
||||
throw new InvalidArgumentError('Domain name too long (max 255 bytes)')
|
||||
}
|
||||
|
||||
return {
|
||||
type: 0x03, // Domain
|
||||
buffer: Buffer.concat([Buffer.from([domainBuffer.length]), domainBuffer])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse IPv6 address to buffer
|
||||
* @param {string} address - IPv6 address string
|
||||
* @returns {Buffer} 16-byte buffer
|
||||
*/
|
||||
function parseIPv6 (address) {
|
||||
const buffer = Buffer.alloc(16)
|
||||
let normalizedAddress = address
|
||||
|
||||
// Expand an embedded IPv4 tail into the last two IPv6 groups.
|
||||
if (address.includes('.')) {
|
||||
const lastColonIndex = address.lastIndexOf(':')
|
||||
const ipv4Part = address.slice(lastColonIndex + 1)
|
||||
|
||||
if (net.isIPv4(ipv4Part)) {
|
||||
const octets = ipv4Part.split('.').map(Number)
|
||||
const high = ((octets[0] << 8) | octets[1]).toString(16)
|
||||
const low = ((octets[2] << 8) | octets[3]).toString(16)
|
||||
normalizedAddress = `${address.slice(0, lastColonIndex)}:${high}:${low}`
|
||||
}
|
||||
}
|
||||
|
||||
// Handle compressed notation (::)
|
||||
const doubleColonIndex = normalizedAddress.indexOf('::')
|
||||
if (doubleColonIndex !== -1) {
|
||||
const before = normalizedAddress.slice(0, doubleColonIndex)
|
||||
const after = normalizedAddress.slice(doubleColonIndex + 2)
|
||||
const beforeParts = before === '' ? [] : before.split(':')
|
||||
const afterParts = after === '' ? [] : after.split(':')
|
||||
|
||||
let bufferIndex = 0
|
||||
for (const part of beforeParts) {
|
||||
buffer.writeUInt16BE(parseInt(part, 16), bufferIndex)
|
||||
bufferIndex += 2
|
||||
}
|
||||
bufferIndex = 16 - afterParts.length * 2
|
||||
for (const part of afterParts) {
|
||||
buffer.writeUInt16BE(parseInt(part, 16), bufferIndex)
|
||||
bufferIndex += 2
|
||||
}
|
||||
} else {
|
||||
const parts = normalizedAddress.split(':')
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
buffer.writeUInt16BE(parseInt(parts[i], 16), i * 2)
|
||||
}
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SOCKS5 address buffer
|
||||
* @param {number} type - Address type (1=IPv4, 3=Domain, 4=IPv6)
|
||||
* @param {Buffer} addressBuffer - The address data
|
||||
* @param {number} port - Port number
|
||||
* @returns {Buffer} Complete address buffer including type, address, and port
|
||||
*/
|
||||
function buildAddressBuffer (type, addressBuffer, port) {
|
||||
const portBuffer = Buffer.allocUnsafe(2)
|
||||
portBuffer.writeUInt16BE(port, 0)
|
||||
|
||||
return Buffer.concat([
|
||||
Buffer.from([type]),
|
||||
addressBuffer,
|
||||
portBuffer
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse address from SOCKS5 response
|
||||
* @param {Buffer} buffer - Buffer containing the address
|
||||
* @param {number} offset - Starting offset in buffer
|
||||
* @returns {{address: string, port: number, bytesRead: number}}
|
||||
*/
|
||||
function parseResponseAddress (buffer, offset = 0) {
|
||||
if (buffer.length < offset + 1) {
|
||||
throw new InvalidArgumentError('Buffer too small to contain address type')
|
||||
}
|
||||
|
||||
const addressType = buffer[offset]
|
||||
let address
|
||||
let currentOffset = offset + 1
|
||||
|
||||
switch (addressType) {
|
||||
case 0x01: { // IPv4
|
||||
if (buffer.length < currentOffset + 6) {
|
||||
throw new InvalidArgumentError('Buffer too small for IPv4 address')
|
||||
}
|
||||
address = Array.from(buffer.subarray(currentOffset, currentOffset + 4)).join('.')
|
||||
currentOffset += 4
|
||||
break
|
||||
}
|
||||
|
||||
case 0x03: { // Domain
|
||||
if (buffer.length < currentOffset + 1) {
|
||||
throw new InvalidArgumentError('Buffer too small for domain length')
|
||||
}
|
||||
const domainLength = buffer[currentOffset]
|
||||
currentOffset += 1
|
||||
|
||||
if (buffer.length < currentOffset + domainLength + 2) {
|
||||
throw new InvalidArgumentError('Buffer too small for domain address')
|
||||
}
|
||||
address = buffer.subarray(currentOffset, currentOffset + domainLength).toString('utf8')
|
||||
currentOffset += domainLength
|
||||
break
|
||||
}
|
||||
|
||||
case 0x04: { // IPv6
|
||||
if (buffer.length < currentOffset + 18) {
|
||||
throw new InvalidArgumentError('Buffer too small for IPv6 address')
|
||||
}
|
||||
// Convert buffer to IPv6 string
|
||||
const parts = []
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const value = buffer.readUInt16BE(currentOffset + i * 2)
|
||||
parts.push(value.toString(16))
|
||||
}
|
||||
address = parts.join(':')
|
||||
currentOffset += 16
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
throw new InvalidArgumentError(`Invalid address type: ${addressType}`)
|
||||
}
|
||||
|
||||
// Parse port
|
||||
if (buffer.length < currentOffset + 2) {
|
||||
throw new InvalidArgumentError('Buffer too small for port')
|
||||
}
|
||||
const port = buffer.readUInt16BE(currentOffset)
|
||||
currentOffset += 2
|
||||
|
||||
return {
|
||||
address,
|
||||
port,
|
||||
bytesRead: currentOffset - offset
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error for SOCKS5 reply code
|
||||
* @param {number} replyCode - SOCKS5 reply code
|
||||
* @returns {Error} Appropriate error object
|
||||
*/
|
||||
function createReplyError (replyCode) {
|
||||
const messages = {
|
||||
0x01: 'General SOCKS server failure',
|
||||
0x02: 'Connection not allowed by ruleset',
|
||||
0x03: 'Network unreachable',
|
||||
0x04: 'Host unreachable',
|
||||
0x05: 'Connection refused',
|
||||
0x06: 'TTL expired',
|
||||
0x07: 'Command not supported',
|
||||
0x08: 'Address type not supported'
|
||||
}
|
||||
|
||||
const message = messages[replyCode] || `Unknown SOCKS5 error code: ${replyCode}`
|
||||
const error = new Error(message)
|
||||
error.code = `SOCKS5_${replyCode}`
|
||||
return error
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseAddress,
|
||||
parseIPv6,
|
||||
buildAddressBuffer,
|
||||
parseResponseAddress,
|
||||
createReplyError
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
'use strict'
|
||||
|
||||
module.exports = {
|
||||
kClose: Symbol('close'),
|
||||
kDestroy: Symbol('destroy'),
|
||||
kDispatch: Symbol('dispatch'),
|
||||
kUrl: Symbol('url'),
|
||||
kRequestOrigin: Symbol('request origin'),
|
||||
kOriginless: Symbol('originless'),
|
||||
kWriting: Symbol('writing'),
|
||||
kResuming: Symbol('resuming'),
|
||||
kQueue: Symbol('queue'),
|
||||
kConnect: Symbol('connect'),
|
||||
kConnecting: Symbol('connecting'),
|
||||
kKeepAliveDefaultTimeout: Symbol('default keep alive timeout'),
|
||||
kKeepAliveMaxTimeout: Symbol('max keep alive timeout'),
|
||||
kKeepAliveTimeoutThreshold: Symbol('keep alive timeout threshold'),
|
||||
kKeepAliveTimeoutValue: Symbol('keep alive timeout'),
|
||||
kKeepAlive: Symbol('keep alive'),
|
||||
kHeadersTimeout: Symbol('headers timeout'),
|
||||
kBodyTimeout: Symbol('body timeout'),
|
||||
kServerName: Symbol('server name'),
|
||||
kLocalAddress: Symbol('local address'),
|
||||
kHost: Symbol('host'),
|
||||
kNoRef: Symbol('no ref'),
|
||||
kBodyUsed: Symbol('used'),
|
||||
kBody: Symbol('abstracted request body'),
|
||||
kRunning: Symbol('running'),
|
||||
kBlocking: Symbol('blocking'),
|
||||
kPending: Symbol('pending'),
|
||||
kSize: Symbol('size'),
|
||||
kBusy: Symbol('busy'),
|
||||
kQueued: Symbol('queued'),
|
||||
kFree: Symbol('free'),
|
||||
kConnected: Symbol('connected'),
|
||||
kClosed: Symbol('closed'),
|
||||
kNeedDrain: Symbol('need drain'),
|
||||
kReset: Symbol('reset'),
|
||||
kDestroyed: Symbol.for('nodejs.stream.destroyed'),
|
||||
kResume: Symbol('resume'),
|
||||
kOnError: Symbol('on error'),
|
||||
kMaxHeadersSize: Symbol('max headers size'),
|
||||
kRunningIdx: Symbol('running index'),
|
||||
kPendingIdx: Symbol('pending index'),
|
||||
kError: Symbol('error'),
|
||||
kClients: Symbol('clients'),
|
||||
kClient: Symbol('client'),
|
||||
kParser: Symbol('parser'),
|
||||
kOnDestroyed: Symbol('destroy callbacks'),
|
||||
kPipelining: Symbol('pipelining'),
|
||||
kSocket: Symbol('socket'),
|
||||
kHostHeader: Symbol('host header'),
|
||||
kConnector: Symbol('connector'),
|
||||
kStrictContentLength: Symbol('strict content length'),
|
||||
kMaxRedirections: Symbol('maxRedirections'),
|
||||
kMaxRequests: Symbol('maxRequestsPerClient'),
|
||||
kProxy: Symbol('proxy agent options'),
|
||||
kCounter: Symbol('socket request counter'),
|
||||
kMaxResponseSize: Symbol('max response size'),
|
||||
kHTTP2Session: Symbol('http2Session'),
|
||||
kHTTP2Options: Symbol('http2 options'),
|
||||
kHTTP2SessionState: Symbol('http2Session state'),
|
||||
kRetryHandlerDefaultRetry: Symbol('retry agent default retry'),
|
||||
kConstruct: Symbol('constructable'),
|
||||
kListeners: Symbol('listeners'),
|
||||
kHTTPContext: Symbol('http context'),
|
||||
kMaxConcurrentStreams: Symbol('max concurrent streams'),
|
||||
kHostAuthority: Symbol('host authority'),
|
||||
kHTTP2InitialWindowSize: Symbol('http2 initial window size'),
|
||||
kHTTP2ConnectionWindowSize: Symbol('http2 connection window size'),
|
||||
kEnableConnectProtocol: Symbol('http2session connect protocol'),
|
||||
kRemoteSettings: Symbol('http2session remote settings'),
|
||||
kHTTP2Stream: Symbol('http2session client stream'),
|
||||
kPingInterval: Symbol('ping interval'),
|
||||
kNoProxyAgent: Symbol('no proxy agent'),
|
||||
kHttpProxyAgent: Symbol('http proxy agent'),
|
||||
kHttpsProxyAgent: Symbol('https proxy agent'),
|
||||
kSocks5ProxyAgent: Symbol('socks5 proxy agent')
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
'use strict'
|
||||
|
||||
const {
|
||||
wellknownHeaderNames,
|
||||
headerNameLowerCasedRecord
|
||||
} = require('./constants')
|
||||
|
||||
class TstNode {
|
||||
/** @type {any} */
|
||||
value = null
|
||||
/** @type {null | TstNode} */
|
||||
left = null
|
||||
/** @type {null | TstNode} */
|
||||
middle = null
|
||||
/** @type {null | TstNode} */
|
||||
right = null
|
||||
/** @type {number} */
|
||||
code
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {any} value
|
||||
* @param {number} index
|
||||
*/
|
||||
constructor (key, value, index) {
|
||||
if (index === undefined || index >= key.length) {
|
||||
throw new TypeError('Unreachable')
|
||||
}
|
||||
const code = this.code = key.charCodeAt(index)
|
||||
// check code is ascii string
|
||||
if (code > 0x7F) {
|
||||
throw new TypeError('key must be ascii string')
|
||||
}
|
||||
if (key.length !== ++index) {
|
||||
this.middle = new TstNode(key, value, index)
|
||||
} else {
|
||||
this.value = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {any} value
|
||||
* @returns {void}
|
||||
*/
|
||||
add (key, value) {
|
||||
const length = key.length
|
||||
if (length === 0) {
|
||||
throw new TypeError('Unreachable')
|
||||
}
|
||||
let index = 0
|
||||
/**
|
||||
* @type {TstNode}
|
||||
*/
|
||||
let node = this
|
||||
while (true) {
|
||||
const code = key.charCodeAt(index)
|
||||
// check code is ascii string
|
||||
if (code > 0x7F) {
|
||||
throw new TypeError('key must be ascii string')
|
||||
}
|
||||
if (node.code === code) {
|
||||
if (length === ++index) {
|
||||
node.value = value
|
||||
break
|
||||
} else if (node.middle !== null) {
|
||||
node = node.middle
|
||||
} else {
|
||||
node.middle = new TstNode(key, value, index)
|
||||
break
|
||||
}
|
||||
} else if (node.code < code) {
|
||||
if (node.left !== null) {
|
||||
node = node.left
|
||||
} else {
|
||||
node.left = new TstNode(key, value, index)
|
||||
break
|
||||
}
|
||||
} else if (node.right !== null) {
|
||||
node = node.right
|
||||
} else {
|
||||
node.right = new TstNode(key, value, index)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} key
|
||||
* @returns {TstNode | null}
|
||||
*/
|
||||
search (key) {
|
||||
const keylength = key.length
|
||||
let index = 0
|
||||
/**
|
||||
* @type {TstNode|null}
|
||||
*/
|
||||
let node = this
|
||||
while (node !== null && index < keylength) {
|
||||
let code = key[index]
|
||||
// A-Z
|
||||
// First check if it is bigger than 0x5a.
|
||||
// Lowercase letters have higher char codes than uppercase ones.
|
||||
// Also we assume that headers will mostly contain lowercase characters.
|
||||
if (code <= 0x5a && code >= 0x41) {
|
||||
// Lowercase for uppercase.
|
||||
code |= 32
|
||||
}
|
||||
while (node !== null) {
|
||||
if (code === node.code) {
|
||||
if (keylength === ++index) {
|
||||
// Returns Node since it is the last key.
|
||||
return node
|
||||
}
|
||||
node = node.middle
|
||||
break
|
||||
}
|
||||
node = node.code < code ? node.left : node.right
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class TernarySearchTree {
|
||||
/** @type {TstNode | null} */
|
||||
node = null
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {any} value
|
||||
* @returns {void}
|
||||
* */
|
||||
insert (key, value) {
|
||||
if (this.node === null) {
|
||||
this.node = new TstNode(key, value, 0)
|
||||
} else {
|
||||
this.node.add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} key
|
||||
* @returns {any}
|
||||
*/
|
||||
lookup (key) {
|
||||
return this.node?.search(key)?.value ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const tree = new TernarySearchTree()
|
||||
|
||||
for (let i = 0; i < wellknownHeaderNames.length; ++i) {
|
||||
const key = headerNameLowerCasedRecord[wellknownHeaderNames[i]]
|
||||
tree.insert(key, key)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TernarySearchTree,
|
||||
tree
|
||||
}
|
||||
+1010
File diff suppressed because it is too large.
Load diff
+177
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+1825
File diff suppressed because it is too large.
Load diff
+746
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
Loaded 100 of 308 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user