From 7373d9fd8d5305fd988dd62c8d29f812a70a5e6e Mon Sep 17 00:00:00 2001 From: ruby Date: Wed, 15 Apr 2026 20:59:35 +0400 Subject: [PATCH 1/3] feat: add transport features (mTLS, DNS, multipart, zstd, charset) --- README.md | 129 ++++- package.json | 2 +- rust/Cargo.lock | 699 +++++++++++++++++++++++++++- rust/Cargo.toml | 4 +- rust/src/napi/convert.rs | 169 ++++++- rust/src/transport/dns.rs | 116 +++++ rust/src/transport/mod.rs | 2 + rust/src/transport/request.rs | 63 ++- rust/src/transport/tls.rs | 51 ++ rust/src/transport/types.rs | 31 +- rust/src/transport/websocket.rs | 46 +- src/config/network.ts | 48 ++ src/config/tls.ts | 62 +++ src/http/body/bytes.ts | 65 +++ src/http/fetch.ts | 4 +- src/http/pipeline/input.ts | 5 +- src/http/pipeline/options.ts | 21 +- src/http/request.ts | 115 ++++- src/http/response.ts | 24 +- src/index.ts | 8 + src/test/fixtures/mtls.ts | 129 +++++ src/test/helpers/local-server.ts | 206 +++++--- src/test/helpers/mtls-server.ts | 79 ++++ src/test/helpers/proxy-server.ts | 74 +++ src/test/http-client.spec.ts | 3 +- src/test/mtls.spec.ts | 63 +++ src/test/node-wreq.spec.ts | 2 + src/test/transport-features.spec.ts | 129 +++++ src/test/websocket.spec.ts | 10 +- src/types/http.ts | 11 +- src/types/native.ts | 28 +- src/types/shared.ts | 26 +- src/types/websocket.ts | 13 +- src/websocket/index.ts | 11 +- 34 files changed, 2266 insertions(+), 182 deletions(-) create mode 100644 rust/src/transport/dns.rs create mode 100644 rust/src/transport/tls.rs create mode 100644 src/config/network.ts create mode 100644 src/config/tls.ts create mode 100644 src/test/fixtures/mtls.ts create mode 100644 src/test/helpers/mtls-server.ts create mode 100644 src/test/helpers/proxy-server.ts create mode 100644 src/test/mtls.spec.ts create mode 100644 src/test/transport-features.spec.ts diff --git a/README.md b/README.md index e5702c6..50425ab 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ TLS and HTTP/2 fingerprinting is actively used by major bot protection and WAF p npm install node-wreq ``` +Node.js 20+ is required. + ## contents #### ⚡   **[quick start](#quick-start)** @@ -57,7 +59,7 @@ npm install node-wreq #### 📊   **[observability](#observability)** #### 🚨   **[error handling](#errors)** #### 🔌   **[websockets](#websockets)** -#### 🧪   **[networking / transport knobs](#networking)** — TLS, HTTP/1, HTTP/2 options; header ordering. +#### 🧪   **[networking / transport knobs](#networking)** — TLS, HTTP/1, HTTP/2 options; header ordering, mTLS and custom CAs; DNS controls. ## ⚡ quick start @@ -137,6 +139,24 @@ const response = await fetch('https://api.example.com/items', { console.log(await response.json()); ``` +### upload `FormData` + +`FormData` request bodies work like `fetch`: the multipart boundary and `content-type` header are generated automatically. + +```ts +const formData = new FormData(); + +formData.append('alpha', '1'); +formData.append('upload', new File(['hello'], 'hello.txt', { type: 'text/plain' })); + +const response = await fetch('https://api.example.com/upload', { + method: 'POST', + body: formData, +}); + +console.log(await response.json()); +``` + ### build a `Request` first ```ts @@ -697,10 +717,44 @@ const response = await fetch('https://httpbin.org/anything', { }); ``` +If you want to bypass env/system proxy detection for a specific request, use `proxy: false`: + +```ts +await fetch('https://example.com', { + proxy: false, +}); +``` + ### disable default browser-like headers By default, `node-wreq` may apply profile-appropriate default headers. +`disableDefaultHeaders: true` disables those browser/profile preset headers only. + +That means it turns off headers injected by the selected browser emulation, such as: + +- `user-agent` +- `accept` +- `accept-language` +- `sec-ch-ua` +- `sec-ch-ua-mobile` +- `sec-ch-ua-platform` +- `sec-fetch-dest` +- `sec-fetch-mode` +- `sec-fetch-site` +- `priority` + +The exact set varies by profile. + +It does **not** disable protocol or transport-level headers that may still appear automatically, such as: + +- `host` +- `accept-encoding` when `compress` is enabled +- `content-length` when the request body requires it +- `content-type` generated by the runtime for bodies like `FormData` + +It also does not remove headers you set explicitly yourself. + If you want full manual control: ```ts @@ -713,27 +767,16 @@ await fetch('https://example.com', { }); ``` -### exact header order - -Use tuples when header order matters: +For example, with `browser: 'chrome_137'`, the default request would normally include Chrome-like `sec-ch-*`, `sec-fetch-*`, `user-agent`, `accept`, and `accept-language` headers. With `disableDefaultHeaders: true`, those browser preset headers are skipped, while transport headers like `host` and `accept-encoding` may still be present. -```ts -await fetch('https://example.com', { - headers: [ - ['x-lower', 'one'], - ['X-Mixed', 'two'], - ], -}); -``` +### exact header order -### exact original header names on the wire +Use tuples when header order matters. -Use this only if you really need exact casing / spelling preservation: +Tuple headers also preserve the original header names exactly as you wrote them on the wire: ```ts await fetch('https://example.com', { - disableDefaultHeaders: true, - keepOriginalHeaderNames: true, headers: [ ['x-lower', 'one'], ['X-Mixed', 'two'], @@ -741,6 +784,8 @@ await fetch('https://example.com', { }); ``` +For example, this will preserve both the tuple order and the exact `x-lower` / `X-Mixed` casing you passed. + ### lower-level transport tuning If a browser preset gets you close but not all the way there: @@ -767,10 +812,47 @@ Use these only when: - you are comparing transport behavior - you want to debug fingerprint mismatches +### mTLS and custom CAs + +Use `tlsIdentity` for client certificate authentication and `ca` for a custom trust store: + +```ts +import { fetch } from 'node-wreq'; +import { readFileSync } from 'node:fs'; + +await fetch('https://mtls.example.com', { + tlsIdentity: { + cert: readFileSync('./client-cert.pem'), + key: readFileSync('./client-key.pem'), + }, + ca: { + cert: readFileSync('./ca.pem'), + includeDefaultRoots: false, + }, +}); +``` + +PKCS#12 / PFX identities are also supported: + +```ts +await fetch('https://mtls.example.com', { + tlsIdentity: { + pfx: readFileSync('./client-identity.p12'), + passphrase: 'secret', + }, + ca: { + cert: readFileSync('./ca.pem'), + includeDefaultRoots: false, + }, +}); +``` + ### compression Compression is enabled by default. +That includes `gzip`, `br`, `deflate`, and `zstd` response decoding when the server supports them. + Disable it if you need stricter control over response handling: ```ts @@ -778,3 +860,18 @@ await fetch('https://example.com/archive', { compress: false, }); ``` + +### DNS controls + +Use `dns.hosts` to pin hostnames to specific IPs, or `dns.servers` to send lookups through specific nameservers: + +```ts +await fetch('https://api.internal.test/health', { + dns: { + servers: ['1.1.1.1', '8.8.8.8'], + hosts: { + 'api.internal.test': ['127.0.0.1'], + }, + }, +}); +``` diff --git a/package.json b/package.json index 7a644bf..8f6cce6 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "ws": "^8.18.3" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "os": [ "darwin", diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 5805c06..8bbc49b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -63,6 +63,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -148,6 +159,12 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytes" version = "1.10.1" @@ -211,6 +228,8 @@ dependencies = [ "compression-core", "flate2", "memchr", + "zstd", + "zstd-safe", ] [[package]] @@ -229,6 +248,22 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -247,6 +282,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.6" @@ -299,6 +364,27 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -327,6 +413,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.5.0" @@ -394,6 +486,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + [[package]] name = "futures-sink" version = "0.3.31" @@ -430,6 +528,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.3.3" @@ -438,10 +547,23 @@ checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasi 0.14.7+wasi-0.2.4", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "glob" version = "0.3.3" @@ -454,6 +576,15 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.0" @@ -466,6 +597,52 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand", + "ring", + "thiserror 2.0.17", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand", + "resolv-conf", + "smallvec", + "thiserror 2.0.17", + "tokio", + "tracing", +] + [[package]] name = "http" version = "1.3.1" @@ -608,6 +785,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -637,6 +820,21 @@ checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", "hashbrown 0.16.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", ] [[package]] @@ -666,10 +864,26 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom", + "getrandom 0.3.3", "libc", ] +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.184" @@ -733,6 +947,22 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -757,7 +987,24 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", ] [[package]] @@ -792,12 +1039,14 @@ name = "node-wreq" version = "0.1.0" dependencies = [ "anyhow", + "hickory-resolver", "neon", "serde", "serde_json", "strum", "thiserror 1.0.69", "tokio", + "webpki-root-certs", "wreq", "wreq-util", ] @@ -823,6 +1072,10 @@ name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "openssl-macros" @@ -882,6 +1135,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -906,6 +1165,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -930,6 +1199,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.9.2" @@ -956,7 +1231,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom", + "getrandom 0.3.3", ] [[package]] @@ -997,6 +1272,26 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3160422bbd54dd5ecfdca71e5fd59b7b8fe2b1697ab2baf64f6d05dcc66d298" +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -1009,6 +1304,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.20" @@ -1138,7 +1439,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1181,6 +1482,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1193,6 +1497,33 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "thiserror" version = "1.0.69" @@ -1274,6 +1605,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.51.1" @@ -1288,7 +1634,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1396,6 +1742,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -1445,12 +1810,30 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1475,6 +1858,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -1511,7 +1905,95 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[package]] @@ -1523,6 +2005,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -1551,6 +2039,44 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1560,12 +2086,164 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "wreq" version = "6.0.0-rc.28" @@ -1577,9 +2255,11 @@ dependencies = [ "brotli", "bytes", "cookie", + "encoding_rs", "flate2", "futures-channel", "futures-util", + "hickory-resolver", "http", "http-body", "http-body-util", @@ -1587,6 +2267,8 @@ dependencies = [ "httparse", "ipnet", "libc", + "mime", + "mime_guess", "percent-encoding", "pin-project-lite", "schnellru", @@ -1594,6 +2276,8 @@ dependencies = [ "serde_json", "smallvec", "socket2", + "sync_wrapper", + "system-configuration", "tokio", "tokio-boring2", "tokio-socks", @@ -1603,6 +2287,7 @@ dependencies = [ "url", "want", "webpki-root-certs", + "windows-registry", "zstd", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 0977cc6..0ec6ca9 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -9,7 +9,7 @@ crate-type = ["cdylib"] [dependencies] # HTTP client with browser impersonation -wreq = { version = "6.0.0-rc.28", default-features = false, features = ["gzip", "brotli", "deflate", "socks", "cookies", "json", "webpki-roots", "ws"] } +wreq = { version = "6.0.0-rc.28", default-features = false, features = ["gzip", "brotli", "deflate", "zstd", "socks", "cookies", "json", "charset", "multipart", "hickory-dns", "system-proxy", "webpki-roots", "ws"] } wreq-util = { version = "3.0.0-rc.10", features = ["emulation-rand", "emulation-serde"] } strum = "0.27.2" @@ -26,6 +26,8 @@ thiserror = "1.0" # Async runtime (if needed) tokio = { version = "1.0", features = ["full"] } +webpki-root-certs = "1.0.3" +hickory-resolver = { version = "0.25.2", features = ["system-config"] } [profile.release] opt-level = 3 diff --git a/rust/src/napi/convert.rs b/rust/src/napi/convert.rs index 45fd8df..552f6a4 100644 --- a/rust/src/napi/convert.rs +++ b/rust/src/napi/convert.rs @@ -1,9 +1,12 @@ use crate::emulation::resolve_emulation; use crate::napi::profiles::parse_browser_emulation; use crate::transport::types::{ - RequestOptions, Response, WebSocketConnectOptions, WebSocketConnection, + CertificateAuthorityOptions, DnsOptions, RequestOptions, Response, TlsIdentityOptions, + WebSocketConnectOptions, WebSocketConnection, }; use neon::prelude::*; +use neon::types::JsBuffer; +use neon::types::buffer::TypedArray; pub(crate) fn js_value_to_string_array( cx: &mut FunctionContext, @@ -85,13 +88,19 @@ pub(crate) fn js_object_to_request_options( let body = obj .get_opt(cx, "body")? - .and_then(|v: Handle| v.downcast::(cx).ok()) - .map(|v| v.value(cx)); + .map(|value| js_value_to_bytes(cx, value)) + .transpose()?; let proxy = obj .get_opt(cx, "proxy")? .and_then(|v: Handle| v.downcast::(cx).ok()) .map(|v| v.value(cx)); + let disable_system_proxy = obj + .get_opt(cx, "disableSystemProxy")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)) + .unwrap_or(false); + let dns = js_object_to_dns_options(cx, obj)?; let timeout = obj .get_opt(cx, "timeout")? @@ -110,6 +119,8 @@ pub(crate) fn js_object_to_request_options( .and_then(|v: Handle| v.downcast::(cx).ok()) .map(|v| v.value(cx)) .unwrap_or(true); + let tls_identity = js_object_to_tls_identity_options(cx, obj)?; + let certificate_authority = js_object_to_certificate_authority_options(cx, obj)?; Ok(RequestOptions { url, @@ -119,9 +130,13 @@ pub(crate) fn js_object_to_request_options( method, body, proxy, + disable_system_proxy, + dns, timeout, disable_default_headers, compress, + tls_identity, + certificate_authority, }) } @@ -165,6 +180,12 @@ pub(crate) fn js_object_to_websocket_options( .get_opt(cx, "proxy")? .and_then(|v: Handle| v.downcast::(cx).ok()) .map(|v| v.value(cx)); + let disable_system_proxy = obj + .get_opt(cx, "disableSystemProxy")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)) + .unwrap_or(false); + let dns = js_object_to_dns_options(cx, obj)?; let timeout = obj .get_opt(cx, "timeout")? @@ -186,6 +207,8 @@ pub(crate) fn js_object_to_websocket_options( } } } + let tls_identity = js_object_to_tls_identity_options(cx, obj)?; + let certificate_authority = js_object_to_certificate_authority_options(cx, obj)?; Ok(WebSocketConnectOptions { url, @@ -193,12 +216,152 @@ pub(crate) fn js_object_to_websocket_options( headers, orig_headers, proxy, + disable_system_proxy, + dns, timeout, disable_default_headers, protocols, + tls_identity, + certificate_authority, }) } +fn js_value_to_bytes(cx: &mut FunctionContext, value: Handle) -> NeonResult> { + let buffer = value.downcast::(cx).or_throw(cx)?; + Ok(buffer.as_slice(cx).to_vec()) +} + +fn js_object_to_tls_identity_options( + cx: &mut FunctionContext, + obj: Handle, +) -> NeonResult> { + let Some(identity_obj) = obj + .get_opt(cx, "tlsIdentity")? + .map(|value: Handle| value.downcast::(cx).or_throw(cx)) + .transpose()? + else { + return Ok(None); + }; + + if let Some(archive) = identity_obj + .get_opt(cx, "pfx")? + .map(|value| js_value_to_bytes(cx, value)) + .transpose()? + { + let passphrase = identity_obj + .get_opt(cx, "passphrase")? + .and_then(|value: Handle| value.downcast::(cx).ok()) + .map(|value| value.value(cx)); + + return Ok(Some(TlsIdentityOptions::Pfx { + archive, + passphrase, + })); + } + + let Some(cert) = identity_obj + .get_opt(cx, "cert")? + .map(|value| js_value_to_bytes(cx, value)) + .transpose()? + else { + return cx.throw_type_error("tlsIdentity.cert must be a Buffer"); + }; + + let Some(key) = identity_obj + .get_opt(cx, "key")? + .map(|value| js_value_to_bytes(cx, value)) + .transpose()? + else { + return cx.throw_type_error("tlsIdentity.key must be a Buffer"); + }; + + Ok(Some(TlsIdentityOptions::Pem { cert, key })) +} + +fn js_object_to_certificate_authority_options( + cx: &mut FunctionContext, + obj: Handle, +) -> NeonResult> { + let Some(authority_obj) = obj + .get_opt(cx, "ca")? + .map(|value: Handle| value.downcast::(cx).or_throw(cx)) + .transpose()? + else { + return Ok(None); + }; + + let certs_array = authority_obj.get::(cx, "certs")?; + let certs = certs_array + .to_vec(cx)? + .into_iter() + .map(|value| js_value_to_bytes(cx, value)) + .collect::>>()?; + let include_default_roots = authority_obj + .get_opt(cx, "includeDefaultRoots")? + .and_then(|value: Handle| value.downcast::(cx).ok()) + .map(|value| value.value(cx)) + .unwrap_or(false); + + Ok(Some(CertificateAuthorityOptions { + certs, + include_default_roots, + })) +} + +fn js_object_to_dns_options( + cx: &mut FunctionContext, + obj: Handle, +) -> NeonResult> { + let Some(dns_obj) = obj + .get_opt(cx, "dns")? + .map(|value: Handle| value.downcast::(cx).or_throw(cx)) + .transpose()? + else { + return Ok(None); + }; + + let servers = dns_obj + .get_opt(cx, "servers")? + .map(|value| js_value_to_string_array(cx, value)) + .transpose()? + .unwrap_or_default(); + + let hosts = dns_obj + .get_opt(cx, "hosts")? + .map(|value: Handle| value.downcast::(cx).or_throw(cx)) + .transpose()? + .map(|hosts_obj| { + let property_names = hosts_obj.get_own_property_names(cx)?; + let mut entries = Vec::with_capacity(property_names.len(cx) as usize); + + for key in property_names.to_vec(cx)? { + let hostname = key.downcast::(cx).or_throw(cx)?.value(cx); + let values = hosts_obj + .get::(cx, hostname.as_str())? + .to_vec(cx)? + .into_iter() + .map(|value| { + value + .downcast::(cx) + .or_throw(cx) + .map(|value| value.value(cx)) + }) + .collect::>>()?; + entries.push((hostname, values)); + } + + Ok(entries) + }) + .transpose()? + .unwrap_or_default(); + + if servers.is_empty() && hosts.is_empty() { + return Ok(None); + } + + Ok(Some(DnsOptions { servers, hosts })) +} + pub(crate) fn response_to_js_object<'a, C: Context<'a>>( cx: &mut C, response: Response, diff --git a/rust/src/transport/dns.rs b/rust/src/transport/dns.rs new file mode 100644 index 0000000..b84be93 --- /dev/null +++ b/rust/src/transport/dns.rs @@ -0,0 +1,116 @@ +use crate::transport::types::DnsOptions; +use anyhow::{Context, Result}; +use hickory_resolver::{ + TokioResolver, + config::{LookupIpStrategy, NameServerConfig, NameServerConfigGroup, ResolverConfig}, + lookup_ip::LookupIpIntoIter, + name_server::TokioConnectionProvider, + proto::xfer::Protocol, +}; +use std::net::{IpAddr, SocketAddr}; +use wreq::dns::{Addrs, Name, Resolve, Resolving}; + +fn parse_ip_or_socket_addr(value: &str, default_port: u16) -> Result { + if let Ok(socket_addr) = value.parse::() { + return Ok(socket_addr); + } + + let ip = value + .parse::() + .with_context(|| format!("Invalid DNS server or override address: {value}"))?; + + Ok(SocketAddr::new(ip, default_port)) +} + +fn parse_override_addresses(addresses: &[String]) -> Result> { + addresses + .iter() + .map(|address| parse_ip_or_socket_addr(address, 0)) + .collect() +} + +fn build_name_server_group(servers: &[String]) -> Result { + let mut group = NameServerConfigGroup::new(); + + for server in servers { + let socket_addr = parse_ip_or_socket_addr(server, 53) + .with_context(|| format!("Invalid dns.servers entry: {server}"))?; + + for protocol in [Protocol::Udp, Protocol::Tcp] { + let mut config = NameServerConfig::new(socket_addr, protocol); + config.trust_negative_responses = true; + group.push(config); + } + } + + Ok(group) +} + +#[derive(Clone, Debug)] +struct CustomDnsResolver { + resolver: TokioResolver, +} + +impl CustomDnsResolver { + fn new(servers: &[String]) -> Result { + let mut builder = TokioResolver::builder_with_config( + ResolverConfig::from_parts(None, Vec::new(), build_name_server_group(servers)?), + TokioConnectionProvider::default(), + ); + + builder.options_mut().ip_strategy = LookupIpStrategy::Ipv4AndIpv6; + + Ok(Self { + resolver: builder.build(), + }) + } +} + +struct SocketAddrs { + iter: LookupIpIntoIter, +} + +impl Resolve for CustomDnsResolver { + fn resolve(&self, name: Name) -> Resolving { + let resolver = self.clone(); + + Box::pin(async move { + let lookup = resolver.resolver.lookup_ip(name.as_str()).await?; + let addrs: Addrs = Box::new(SocketAddrs { + iter: lookup.into_iter(), + }); + + Ok(addrs) + }) + } +} + +impl Iterator for SocketAddrs { + type Item = SocketAddr; + + fn next(&mut self) -> Option { + self.iter.next().map(|ip_addr| SocketAddr::new(ip_addr, 0)) + } +} + +pub fn configure_client_builder( + mut client_builder: wreq::ClientBuilder, + dns: Option, +) -> Result { + client_builder = client_builder.no_hickory_dns(); + + let Some(dns) = dns else { + return Ok(client_builder); + }; + + if !dns.servers.is_empty() { + client_builder = client_builder.dns_resolver(CustomDnsResolver::new(&dns.servers)?); + } + + for (hostname, addresses) in dns.hosts { + let parsed = parse_override_addresses(&addresses)?; + client_builder = client_builder.resolve_to_addrs(hostname, parsed); + } + + Ok(client_builder) +} diff --git a/rust/src/transport/mod.rs b/rust/src/transport/mod.rs index b41b8f6..dbb932b 100644 --- a/rust/src/transport/mod.rs +++ b/rust/src/transport/mod.rs @@ -1,6 +1,8 @@ mod cookies; +mod dns; mod headers; mod request; +mod tls; pub mod types; mod websocket; diff --git a/rust/src/transport/request.rs b/rust/src/transport/request.rs index df27cb0..a3d73b1 100644 --- a/rust/src/transport/request.rs +++ b/rust/src/transport/request.rs @@ -1,7 +1,9 @@ use crate::store::body_store::store_body; use crate::store::runtime::runtime; use crate::transport::cookies::parse_cookie_pair; +use crate::transport::dns::configure_client_builder as configure_dns; use crate::transport::headers::build_orig_header_map; +use crate::transport::tls::configure_client_builder; use crate::transport::types::{RequestOptions, Response}; use anyhow::{Context, Result}; use std::collections::HashMap; @@ -13,37 +15,59 @@ pub fn execute_request(options: RequestOptions) -> Result { } pub async fn make_request(options: RequestOptions) -> Result { + let RequestOptions { + url, + emulation, + headers, + orig_headers, + method, + body, + proxy, + disable_system_proxy, + dns, + timeout, + disable_default_headers, + compress, + tls_identity, + certificate_authority, + } = options; + let mut client_builder = wreq::Client::builder() - .emulation(options.emulation) + .emulation(emulation) .cookie_store(true); - if let Some(proxy_url) = &options.proxy { + if disable_system_proxy { + client_builder = client_builder.no_proxy(); + } else if let Some(proxy_url) = &proxy { let proxy = wreq::Proxy::all(proxy_url).context("Failed to create proxy")?; client_builder = client_builder.proxy(proxy); } - let orig_headers = build_orig_header_map(&options.orig_headers); + client_builder = configure_dns(client_builder, dns)?; + client_builder = configure_client_builder(client_builder, tls_identity, certificate_authority)?; + + let orig_headers = build_orig_header_map(&orig_headers); let client = client_builder .build() .context("Failed to build HTTP client")?; - let method = if options.method.is_empty() { + let method = if method.is_empty() { "GET" } else { - &options.method + &method }; let mut request = match method.to_uppercase().as_str() { - "GET" => client.get(&options.url), - "POST" => client.post(&options.url), - "PUT" => client.put(&options.url), - "DELETE" => client.delete(&options.url), - "PATCH" => client.patch(&options.url), - "HEAD" => client.head(&options.url), + "GET" => client.get(&url), + "POST" => client.post(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + "PATCH" => client.patch(&url), + "HEAD" => client.head(&url), _ => return Err(anyhow::anyhow!("Unsupported HTTP method: {}", method)), }; - for (key, value) in &options.headers { + for (key, value) in &headers { request = request.header(key, value); } @@ -51,21 +75,22 @@ pub async fn make_request(options: RequestOptions) -> Result { request = request.orig_headers(orig_headers); } - if let Some(body) = options.body { + if let Some(body) = body { request = request.body(body); } - request = request.timeout(Duration::from_millis(options.timeout)); + request = request.timeout(Duration::from_millis(timeout)); request = request.redirect(redirect::Policy::none()); - request = request.default_headers(!options.disable_default_headers); - request = request.gzip(options.compress); - request = request.brotli(options.compress); - request = request.deflate(options.compress); + request = request.default_headers(!disable_default_headers); + request = request.gzip(compress); + request = request.brotli(compress); + request = request.zstd(compress); + request = request.deflate(compress); let response = request .send() .await - .with_context(|| format!("{} {}", method, options.url))?; + .with_context(|| format!("{} {}", method, url))?; let status = response.status().as_u16(); let final_url = response.uri().to_string(); diff --git a/rust/src/transport/tls.rs b/rust/src/transport/tls.rs new file mode 100644 index 0000000..3dcad38 --- /dev/null +++ b/rust/src/transport/tls.rs @@ -0,0 +1,51 @@ +use crate::transport::types::{CertificateAuthorityOptions, TlsIdentityOptions}; +use anyhow::{Context, Result}; +use wreq::{ + ClientBuilder, + tls::{CertStore, Identity}, +}; + +pub fn configure_client_builder( + mut builder: ClientBuilder, + tls_identity: Option, + certificate_authority: Option, +) -> Result { + if let Some(tls_identity) = tls_identity { + builder = builder.identity(build_identity(tls_identity)?); + } + + if let Some(certificate_authority) = certificate_authority { + builder = builder.cert_store(build_cert_store(certificate_authority)?); + } + + Ok(builder) +} + +fn build_identity(options: TlsIdentityOptions) -> Result { + match options { + TlsIdentityOptions::Pem { cert, key } => { + Identity::from_pkcs8_pem(&cert, &key).context("Failed to parse TLS identity from PEM") + } + TlsIdentityOptions::Pfx { + archive, + passphrase, + } => Identity::from_pkcs12_der(&archive, passphrase.as_deref().unwrap_or("")) + .context("Failed to parse TLS identity from PKCS#12"), + } +} + +fn build_cert_store(options: CertificateAuthorityOptions) -> Result { + let mut builder = CertStore::builder(); + + if options.include_default_roots { + builder = builder.add_der_certs(webpki_root_certs::TLS_SERVER_ROOT_CERTS); + } + + for cert in &options.certs { + builder = builder.add_stack_pem_certs(cert); + } + + builder + .build() + .context("Failed to build TLS certificate store") +} diff --git a/rust/src/transport/types.rs b/rust/src/transport/types.rs index f4c036d..8687d52 100644 --- a/rust/src/transport/types.rs +++ b/rust/src/transport/types.rs @@ -1,6 +1,27 @@ use std::collections::HashMap; use wreq::Emulation; +#[derive(Debug, Clone)] +pub enum TlsIdentityOptions { + Pem { cert: Vec, key: Vec }, + Pfx { + archive: Vec, + passphrase: Option, + }, +} + +#[derive(Debug, Clone)] +pub struct CertificateAuthorityOptions { + pub certs: Vec>, + pub include_default_roots: bool, +} + +#[derive(Debug, Clone)] +pub struct DnsOptions { + pub servers: Vec, + pub hosts: Vec<(String, Vec)>, +} + #[derive(Debug, Clone)] pub struct RequestOptions { pub url: String, @@ -8,11 +29,15 @@ pub struct RequestOptions { pub headers: Vec<(String, String)>, pub orig_headers: Vec, pub method: String, - pub body: Option, + pub body: Option>, pub proxy: Option, + pub disable_system_proxy: bool, + pub dns: Option, pub timeout: u64, pub disable_default_headers: bool, pub compress: bool, + pub tls_identity: Option, + pub certificate_authority: Option, } #[derive(Debug, Clone)] @@ -32,9 +57,13 @@ pub struct WebSocketConnectOptions { pub headers: Vec<(String, String)>, pub orig_headers: Vec, pub proxy: Option, + pub disable_system_proxy: bool, + pub dns: Option, pub timeout: u64, pub disable_default_headers: bool, pub protocols: Vec, + pub tls_identity: Option, + pub certificate_authority: Option, } #[derive(Debug, Clone)] diff --git a/rust/src/transport/websocket.rs b/rust/src/transport/websocket.rs index b93b120..075de33 100644 --- a/rust/src/transport/websocket.rs +++ b/rust/src/transport/websocket.rs @@ -1,6 +1,8 @@ use crate::store::runtime::runtime; use crate::store::websocket_store::{insert_websocket, WebSocketCommand}; +use crate::transport::dns::configure_client_builder as configure_dns; use crate::transport::headers::build_orig_header_map; +use crate::transport::tls::configure_client_builder; use crate::transport::types::{WebSocketConnectOptions, WebSocketConnection, WebSocketReadResult}; use anyhow::{Context, Result}; use std::time::Duration; @@ -131,23 +133,43 @@ async fn run_websocket_task( } async fn make_websocket(options: WebSocketConnectOptions) -> Result { + let WebSocketConnectOptions { + url, + emulation, + headers, + orig_headers, + proxy, + disable_system_proxy, + dns, + timeout, + disable_default_headers, + protocols, + tls_identity, + certificate_authority, + } = options; + let mut client_builder = wreq::Client::builder() - .emulation(options.emulation) + .emulation(emulation) .cookie_store(true) - .timeout(Duration::from_millis(options.timeout)); + .timeout(Duration::from_millis(timeout)); - if let Some(proxy_url) = &options.proxy { + if disable_system_proxy { + client_builder = client_builder.no_proxy(); + } else if let Some(proxy_url) = &proxy { let proxy = wreq::Proxy::all(proxy_url).context("Failed to create proxy")?; client_builder = client_builder.proxy(proxy); } + client_builder = configure_dns(client_builder, dns)?; + client_builder = configure_client_builder(client_builder, tls_identity, certificate_authority)?; + let client = client_builder .build() .context("Failed to build WebSocket client")?; - let mut request = client.websocket(&options.url); - let orig_headers = build_orig_header_map(&options.orig_headers); - for (key, value) in &options.headers { + let mut request = client.websocket(&url); + let orig_headers = build_orig_header_map(&orig_headers); + for (key, value) in &headers { request = request.header(key, value); } @@ -155,16 +177,16 @@ async fn make_websocket(options: WebSocketConnectOptions) -> Result Result Result server.trim()) + .filter((server) => server.length > 0) + : undefined; + + const hosts = dns.hosts + ? Object.fromEntries( + Object.entries(dns.hosts).map(([hostname, value]) => [ + hostname, + Array.isArray(value) ? [...value] : [value], + ]) + ) + : undefined; + + if ((!servers || servers.length === 0) && !hosts) { + return undefined; + } + + return { + servers, + hosts, + }; +} diff --git a/src/config/tls.ts b/src/config/tls.ts new file mode 100644 index 0000000..2a8e4e0 --- /dev/null +++ b/src/config/tls.ts @@ -0,0 +1,62 @@ +import { Buffer } from 'node:buffer'; +import type { + CertificateAuthority, + NativeCertificateAuthority, + NativeTlsIdentity, + TlsBinaryInput, + TlsDataInput, + TlsIdentity, +} from '../types'; + +function toBuffer(input: TlsDataInput | TlsBinaryInput): Buffer { + if (Buffer.isBuffer(input)) { + return Buffer.from(input); + } + + if (typeof input === 'string') { + return Buffer.from(input, 'utf8'); + } + + if (input instanceof ArrayBuffer) { + return Buffer.from(input); + } + + return Buffer.from(input.buffer, input.byteOffset, input.byteLength); +} + +export function normalizeTlsIdentity(identity?: TlsIdentity): NativeTlsIdentity | undefined { + if (!identity) { + return undefined; + } + + if ('pfx' in identity) { + return { + pfx: toBuffer(identity.pfx), + passphrase: identity.passphrase, + }; + } + + return { + cert: toBuffer(identity.cert), + key: toBuffer(identity.key), + }; +} + +export function normalizeCertificateAuthority( + authority?: CertificateAuthority +): NativeCertificateAuthority | undefined { + if (!authority) { + return undefined; + } + + const certs = (Array.isArray(authority.cert) ? authority.cert : [authority.cert]).map(toBuffer); + + if (certs.length === 0) { + throw new TypeError('ca.cert must include at least one certificate'); + } + + return { + certs, + includeDefaultRoots: authority.includeDefaultRoots ?? false, + }; +} diff --git a/src/http/body/bytes.ts b/src/http/body/bytes.ts index 621d3ff..82a757f 100644 --- a/src/http/body/bytes.ts +++ b/src/http/body/bytes.ts @@ -1,6 +1,49 @@ import { Buffer } from 'node:buffer'; import type { BodyInit } from '../../types'; +const FORM_DATA_PLACEHOLDER_URL = 'http://node-wreq.invalid/'; + +function isFileValue(value: string | Blob): value is File { + return typeof File !== 'undefined' && value instanceof File; +} + +export function isFormDataBody(body: BodyInit | null | undefined): body is FormData { + return typeof FormData !== 'undefined' && body instanceof FormData; +} + +export function cloneFormData(body: FormData): FormData { + const cloned = new FormData(); + + for (const [name, value] of body.entries()) { + if (typeof value === 'string') { + cloned.append(name, value); + + continue; + } + + if (isFileValue(value)) { + cloned.append(name, value, value.name); + + continue; + } + + cloned.append(name, value); + } + + return cloned; +} + +export function createMultipartRequest(body: FormData): globalThis.Request { + if (typeof globalThis.Request === 'undefined') { + throw new TypeError('multipart/form-data requests require global Request support'); + } + + return new globalThis.Request(FORM_DATA_PLACEHOLDER_URL, { + method: 'POST', + body, + }); +} + export function toBodyBytes( body: BodyInit | null | undefined, errorMessage = 'Unsupported body type' @@ -32,6 +75,28 @@ export function toBodyBytes( throw new TypeError(errorMessage); } +export function cloneBodyInit(body: BodyInit | null | undefined): BodyInit | null { + if (body === undefined || body === null) { + return null; + } + + if (isFormDataBody(body)) { + return cloneFormData(body); + } + + if (typeof body === 'string') { + return body; + } + + if (body instanceof URLSearchParams) { + return new URLSearchParams(body); + } + + const bytes = toBodyBytes(body); + + return bytes ? cloneBytes(bytes) : null; +} + export function cloneBytes(bytes: Uint8Array | null): Uint8Array | null { return bytes ? new Uint8Array(bytes) : null; } diff --git a/src/http/fetch.ts b/src/http/fetch.ts index d43bc07..59f1d4c 100644 --- a/src/http/fetch.ts +++ b/src/http/fetch.ts @@ -61,7 +61,7 @@ export async function fetch(input: RequestInput, init?: WreqInit) { let response = shortCircuit ?? - (await dispatchNativeRequest(buildNativeRequest(request, options), startTime)); + (await dispatchNativeRequest(await buildNativeRequest(request, options), startTime)); if (shortCircuit) { response.setTimings({ @@ -131,7 +131,7 @@ export async function fetch(input: RequestInput, init?: WreqInit) { const rewritten = rewriteRedirectMethodAndBody( normalizeMethod(request.method), response.status, - request._cloneBodyBytes() ?? undefined + (await request._cloneBodyBytes()) ?? undefined ); const nextRequest = request._replace({ diff --git a/src/http/pipeline/input.ts b/src/http/pipeline/input.ts index fe2223b..cbbcb31 100644 --- a/src/http/pipeline/input.ts +++ b/src/http/pipeline/input.ts @@ -28,7 +28,10 @@ export async function mergeInputAndInit( method: init?.method ?? input.method, headers: init?.headers ?? input.headers, signal: init?.signal ?? input.signal ?? undefined, - body: init?.body !== undefined ? init.body : (input._cloneBodyBytes() ?? undefined), + body: + init?.body !== undefined + ? init.body + : ((await input._cloneBodyBytes()) ?? undefined), } : { ...init }, }; diff --git a/src/http/pipeline/options.ts b/src/http/pipeline/options.ts index 0747fba..dc9ead6 100644 --- a/src/http/pipeline/options.ts +++ b/src/http/pipeline/options.ts @@ -1,4 +1,7 @@ +import { Buffer } from 'node:buffer'; import { serializeEmulationOptions } from '../../config/emulation'; +import { normalizeDnsOptions, normalizeProxyOptions } from '../../config/network'; +import { normalizeCertificateAuthority, normalizeTlsIdentity } from '../../config/tls'; import { Headers } from '../../headers'; import { normalizeMethod, validateBrowserProfile } from '../../native'; import type { @@ -66,7 +69,6 @@ export function resolveOptions(init: WreqInit): ResolvedOptions { throwHttpErrors: init.throwHttpErrors ?? false, disableDefaultHeaders: init.disableDefaultHeaders ?? false, compress: init.compress ?? true, - keepOriginalHeaderNames: init.keepOriginalHeaderNames ?? false, }; } @@ -81,21 +83,28 @@ export function createRequest(urlInput: string | URL, options: ResolvedOptions): }); } -export function buildNativeRequest( +export async function buildNativeRequest( request: Request, options: ResolvedOptions -): NativeRequestOptions { +): Promise { + const { proxy, disableSystemProxy } = normalizeProxyOptions(options.proxy); + const body = await request._getBodyBytesForDispatch(); + return { url: request.url, method: normalizeMethod(request.method), headers: request.headers.toTuples(), - origHeaders: options.keepOriginalHeaderNames ? request.headers.toOriginalNames() : undefined, - body: request._getBodyTextForDispatch(), + origHeaders: request.headers.toOriginalNames(), + body: body ? Buffer.from(body) : undefined, browser: options.browser, emulationJson: serializeEmulationOptions(options), - proxy: options.proxy, + proxy, + disableSystemProxy, + dns: normalizeDnsOptions(options.dns), timeout: options.timeout, disableDefaultHeaders: options.disableDefaultHeaders, compress: options.compress, + tlsIdentity: normalizeTlsIdentity(options.tlsIdentity), + ca: normalizeCertificateAuthority(options.ca), }; } diff --git a/src/http/request.ts b/src/http/request.ts index d30b291..87b0b89 100644 --- a/src/http/request.ts +++ b/src/http/request.ts @@ -2,7 +2,13 @@ import { Blob, Buffer } from 'node:buffer'; import { ReadableStream } from 'node:stream/web'; import { Headers } from '../headers'; import type { BodyInit, HeadersInit, WreqInit } from '../types'; -import { cloneBytes, toBodyBytes } from './body/bytes'; +import { + cloneBodyInit, + cloneBytes, + createMultipartRequest, + isFormDataBody, + toBodyBytes, +} from './body/bytes'; export class Request { readonly url: string; @@ -10,6 +16,7 @@ export class Request { readonly headers: Headers; readonly signal: AbortSignal | null; #bodyBytes: Uint8Array | null; + #multipartBody: globalThis.Request | null; #bodyUsed = false; #stream: ReadableStream | null = null; @@ -23,10 +30,15 @@ export class Request { this.method = (init.method ?? input.method).toUpperCase(); this.headers = new Headers(init.headers ?? input.headers); this.signal = init.signal ?? input.signal ?? null; - this.#bodyBytes = - init.body !== undefined - ? toBodyBytes(init.body, 'Unsupported request body type') - : cloneBytes(input.#bodyBytes); + this.#bodyBytes = null; + this.#multipartBody = null; + + if (init.body !== undefined) { + this.#setBody(init.body); + } else { + this.#bodyBytes = cloneBytes(input.#bodyBytes); + this.#multipartBody = input.#multipartBody?.clone() ?? null; + } return; } @@ -35,18 +47,20 @@ export class Request { this.method = (init.method ?? 'GET').toUpperCase(); this.headers = new Headers(init.headers); this.signal = init.signal ?? null; - this.#bodyBytes = toBodyBytes(init.body, 'Unsupported request body type'); + this.#bodyBytes = null; + this.#multipartBody = null; + this.#setBody(init.body); } get body(): ReadableStream | null { - if (this.#bodyUsed || this.#bodyBytes === null) { + if (this.#bodyUsed || (this.#bodyBytes === null && this.#multipartBody === null)) { return null; } this.#bodyUsed = true; this.#stream ??= new ReadableStream({ - start: (controller) => { - controller.enqueue(cloneBytes(this.#bodyBytes)!); + start: async (controller) => { + controller.enqueue(await this.#readBodyBytes()); controller.close(); }, }); @@ -75,7 +89,7 @@ export class Request { } async text(): Promise { - return Buffer.from(this.#consumeBytes()).toString('utf8'); + return Buffer.from(await this.#consumeBytes()).toString('utf8'); } async json(): Promise { @@ -83,14 +97,24 @@ export class Request { } async arrayBuffer(): Promise { - return Uint8Array.from(this.#consumeBytes()).buffer; + return Uint8Array.from(await this.#consumeBytes()).buffer; } async blob(): Promise { - return new Blob([this.#consumeBytes()]); + return new Blob([await this.#consumeBytes()]); } async formData(): Promise { + if (this.#multipartBody) { + if (this.#bodyUsed) { + throw new TypeError('Request body is already used'); + } + + this.#bodyUsed = true; + + return this.#multipartBody.clone().formData(); + } + const contentType = this.headers.get('content-type')?.toLowerCase() ?? ''; if (!contentType.includes('application/x-www-form-urlencoded')) { @@ -107,20 +131,24 @@ export class Request { return formData; } - _cloneBodyBytes(): Uint8Array | null { - return cloneBytes(this.#bodyBytes); - } + async _cloneBodyBytes(): Promise { + if (this.#bodyBytes !== null) { + return cloneBytes(this.#bodyBytes); + } - _getBodyTextForDispatch(): string | undefined { - if (this.#bodyBytes === null) { - return undefined; + if (!this.#multipartBody) { + return null; } - return Buffer.from(this.#bodyBytes).toString('utf8'); + return new Uint8Array(await this.#multipartBody.clone().arrayBuffer()); + } + + async _getBodyBytesForDispatch(): Promise { + return (await this._cloneBodyBytes()) ?? undefined; } _markBodyUsed(): void { - if (this.#bodyBytes !== null) { + if (this.#bodyBytes !== null || this.#multipartBody !== null) { this.#bodyUsed = true; } } @@ -141,19 +169,62 @@ export class Request { if (!hasBodyOverride) { next.#bodyBytes = cloneBytes(this.#bodyBytes); + next.#multipartBody = this.#multipartBody?.clone() ?? null; } return next; } - #consumeBytes(): Uint8Array { + #setBody(body: BodyInit | null | undefined): void { + const nextBody = cloneBodyInit(body); + + this.#stream = null; + + if (nextBody === null) { + this.#bodyBytes = null; + this.#multipartBody = null; + + return; + } + + if (isFormDataBody(nextBody)) { + const multipartBody = createMultipartRequest(nextBody); + const contentType = multipartBody.headers.get('content-type'); + + this.#bodyBytes = null; + this.#multipartBody = multipartBody; + + if (contentType) { + this.headers.set('content-type', contentType); + } + + return; + } + + this.#bodyBytes = toBodyBytes(nextBody, 'Unsupported request body type'); + this.#multipartBody = null; + } + + async #readBodyBytes(): Promise { + if (this.#bodyBytes !== null) { + return cloneBytes(this.#bodyBytes) ?? new Uint8Array(); + } + + if (this.#multipartBody) { + return new Uint8Array(await this.#multipartBody.clone().arrayBuffer()); + } + + return new Uint8Array(); + } + + async #consumeBytes(): Promise { if (this.#bodyUsed) { throw new TypeError('Request body is already used'); } this.#bodyUsed = true; - return cloneBytes(this.#bodyBytes) ?? new Uint8Array(); + return this.#readBodyBytes(); } } diff --git a/src/http/response.ts b/src/http/response.ts index 55fe68f..70d9088 100644 --- a/src/http/response.ts +++ b/src/http/response.ts @@ -1,6 +1,7 @@ import { Blob, Buffer } from 'node:buffer'; import { STATUS_CODES } from 'node:http'; import { ReadableStream } from 'node:stream/web'; +import { TextDecoder } from 'node:util'; import { Headers } from '../headers'; import { nativeCancelBody, nativeReadBodyChunk } from '../native'; import type { @@ -19,6 +20,27 @@ type ResponseInitWithUrl = ResponseInit & { url?: string; }; +function resolveCharset(contentType: string | null): string { + if (!contentType) { + return 'utf-8'; + } + + const match = contentType.match(/charset\s*=\s*(?:"([^"]+)"|([^;]+))/i); + const label = (match?.[1] ?? match?.[2] ?? 'utf-8').trim(); + + return label || 'utf-8'; +} + +function decodeText(bytes: Uint8Array, contentType: string | null): string { + const charset = resolveCharset(contentType); + + try { + return new TextDecoder(charset).decode(bytes); + } catch { + return new TextDecoder('utf-8').decode(bytes); + } +} + function toHeadersInit(headers: ResponseInit['headers'] | undefined): HeadersInit | undefined { if (headers === undefined) { return undefined; @@ -123,7 +145,7 @@ export class Response { } async text(): Promise { - return Buffer.from(await this.#consumeBytes()).toString('utf8'); + return decodeText(await this.#consumeBytes(), this.headers.get('content-type')); } async json(): Promise { diff --git a/src/index.ts b/src/index.ts index 3705d94..f47e21a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,10 +16,12 @@ import type { BeforeRequestContext, BodyInit, BrowserProfile, + CertificateAuthority, Client, ClientDefaults, CookieJar, CookieJarCookie, + DnsOptions, HeaderTuple, HeadersInit, HookState, @@ -35,6 +37,7 @@ import type { InitContext, NativeRequestOptions, NativeResponse, + NativeDnsOptions, NativeWebSocketConnectOptions, NativeWebSocketConnection, NativeWebSocketReadResult, @@ -47,6 +50,7 @@ import type { ResolvedRetryOptions, RetryDecisionContext, RetryOptions, + TlsIdentity, TlsOptions, TlsVersion, WebSocketBinaryType, @@ -84,10 +88,12 @@ export type { BeforeRequestContext, BodyInit, BrowserProfile, + CertificateAuthority, Client, ClientDefaults, CookieJar, CookieJarCookie, + DnsOptions, HeaderTuple, HeadersInit, HookState, @@ -103,6 +109,7 @@ export type { InitContext, NativeRequestOptions, NativeResponse, + NativeDnsOptions, NativeWebSocketConnectOptions, NativeWebSocketConnection, NativeWebSocketReadResult, @@ -115,6 +122,7 @@ export type { ResolvedRetryOptions, RetryDecisionContext, RetryOptions, + TlsIdentity, TlsOptions, TlsVersion, WebSocketBinaryType, diff --git a/src/test/fixtures/mtls.ts b/src/test/fixtures/mtls.ts new file mode 100644 index 0000000..d2da407 --- /dev/null +++ b/src/test/fixtures/mtls.ts @@ -0,0 +1,129 @@ +export const testCaPem = `-----BEGIN CERTIFICATE----- +MIIDKTCCAhGgAwIBAgIUQrNKBDpHQrO8ALPH8LKpJTqORbQwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbm9kZS13cmVxIFRlc3QgQ0EwHhcNMjYwNDEzMDE1NjQ4 +WhcNMzYwNDEwMDE1NjQ4WjAcMRowGAYDVQQDDBFub2RlLXdyZXEgVGVzdCBDQTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANSFLCKyrATbbXr4UBYXQOG+ +63Gmq9O2c+KAntUavlWfPu71YkAkVkjOROiwqs5c3JWjBOkpT4C0X7QYF/44+SIr +hUh34MRfqy9d0UgK/AFYlQ0pByRaVhilyXH9qtzSy1OcJGD7+VGukCHM7GSYHUzL +HdGTjceyBolcsR5EihjuiEpDEUL0hoTSCdkThmbhxa9H5MOnFX/jROvR2jBHuE14 +WPR7cd/h7Ux4hokiU6nxOysIs2i9RII0lZg/suEQ6upm/xc1RUZntebY/GgIIoeE +WpPqu/CIkRrXRO6mK0UX+rtOLdZvw3dEwRB1My+GUmc0Fy3BAcyD2wRSKPLvJRkC +AwEAAaNjMGEwHQYDVR0OBBYEFA+Sfvgaelywon77yZzm3i/g/ILVMB8GA1UdIwQY +MBaAFA+Sfvgaelywon77yZzm3i/g/ILVMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P +AQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4IBAQA6mNn+Nr7qESy3asLSflhGIDVz +kfcUsmPLE9tzxQEVgeICWUGvquNpKLnBMf5KO/Weow0oDRVUokwNkgnqhHArtWR/ +Br322JLy+Ko99GuRpNWabQE7jpaw9tojkzK2BLQ4kYAKBXmLJQPoGPFEtDfzyyKO +6LFMmDdomYc5xWo0UNCnDlAne6RYfDvENQPOmNg2UnPctLKbdjhuawq8akBeYgxQ +e6K3C1XoZsXdQaOEtVf4gFKBXvmXMtH6UVO0ddNqFN6O4oAEjHUTjZLhX8aTrs0T +bzj/+g6Z2wHsXZSpIaMirtEKrERnGrpITws1cUcV37jofDwTSATjy3Rk+QHE +-----END CERTIFICATE----- +`; + +export const testServerCertPem = `-----BEGIN CERTIFICATE----- +MIIDQzCCAiugAwIBAgIURndtrmjoLJwmOabSoGqQpr/KpL8wDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbm9kZS13cmVxIFRlc3QgQ0EwHhcNMjYwNDEzMDE1NjQ4 +WhcNMzYwNDEwMDE1NjQ4WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFLKt2gEaIwzMubLmqBuh/KxOzq8AXvPCz +1ioSmIAEqwG+tFO3WEH/ubm8N+c8wNQcJc5/ttBeIssWaVnrZSt/yH8+QsUEudF6 +N6XghIpV1pPoUxxka56DehZ0LAHPoryfbq/U240qcle3TxlJWfg7ZE+6HvsRnPfL +upMDf8OhaNXureW6poTh2AesmKzfLvMzNNzwObopPIq2FLjN1YX0UFYMja4Foe8B +iG96C8Au5PVICivRE2B+nnjC1iugcS8PjwyPGZO+KFhIDAXZXKptSF7sJzeEmfQJ +pS2RwtUhrnEmRrBs0wjQ7Q5sHkQnG3wg82SraBLToomT+vq3E8TZAgMBAAGjgYQw +gYEwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMBMGA1UdJQQMMAoGCCsGAQUF +BwMBMA4GA1UdDwEB/wQEAwIFoDAdBgNVHQ4EFgQUDixpgqNM0sCPO2x1clUFeMAJ +8E4wHwYDVR0jBBgwFoAUD5J++Bp6XLCifvvJnObeL+D8gtUwDQYJKoZIhvcNAQEL +BQADggEBACQnr1qZm/pHgiDof6uzakrZpahb7CABiEmt00sQXpeGOjQtQnyRN85N +e2iz6E4l56UEz5hLtoGLLi56kcd8d2FM/SetLKamNVRU4JlFfGtPFIjTOSfKCSrT +taqKRwmjVDRxjofukuU5s22PJviWDm0BVKoAITKjfCWci50SHXEW5QC0VK/XYMGp +WSkVphBW7Psgr7uZP0BUoP+BOV59JkBiIGYAQwUqoq+cCJetjWEfvwZ2s9mVEULb +mSAw9sukDPK2ygmon5XWgR6VIcuAvY1SDDBIbZAPS+B0FTJcWO6OHoZrqodnLZMA +3U5mqCQhduyu1CuNXDS23qy//xiQLZQ= +-----END CERTIFICATE----- +`; + +export const testServerKeyPem = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDFLKt2gEaIwzMu +bLmqBuh/KxOzq8AXvPCz1ioSmIAEqwG+tFO3WEH/ubm8N+c8wNQcJc5/ttBeIssW +aVnrZSt/yH8+QsUEudF6N6XghIpV1pPoUxxka56DehZ0LAHPoryfbq/U240qcle3 +TxlJWfg7ZE+6HvsRnPfLupMDf8OhaNXureW6poTh2AesmKzfLvMzNNzwObopPIq2 +FLjN1YX0UFYMja4Foe8BiG96C8Au5PVICivRE2B+nnjC1iugcS8PjwyPGZO+KFhI +DAXZXKptSF7sJzeEmfQJpS2RwtUhrnEmRrBs0wjQ7Q5sHkQnG3wg82SraBLToomT ++vq3E8TZAgMBAAECggEACCBQLMEapxZMbLRkboJ8YIlY9YW1lATgcrj34O5SEF8v +4jkyXhMQHUb9RamUKSfVVhekzBSvv8F1V0YeQ5pW0Zxkyh8cWZaC8KOMfZVY0jLW +w7NxLx5UWRzWAhGyPWl6lqOMMZye08oEnXRwGVrQ5Tm8I3KVbGNovwfAeVtxp5xE +0yVNNtf0tv/bk8jFD6Z8v6FDJGp/yf5C9KeYocZthtGqNeL4DEp8UV2b2dxdMKqV +IH/U+omjsqOnzcZz8RJHxdZV9w1f2dv5iQtCbADPOMYw8HtlBBOe1lcfhriBBBuE +tKuVUW54FyVsm51YNPXPktJqYaZPVdL1Zri6vRpH0QKBgQD+e3QFzLbWSr9KuSRq +YYfnP4Y3C+OpVRE3WG8WYIRDBxuFUZ1Ng5H++B1gPiX6Txf92IBsjr4yqJ349lr+ +KNljZNJKfHfjyL/qDcqY9zrBGeR5Sa4at/4QldYkPq+aOkGphcD4dEUS5Y6s+XRg +oBpJJRa9YL3xn27ODxD+JfFoJQKBgQDGWbflysHBdKMoFgQOIfLVKHk2vu6kQmSs +FmpGdJwb2pyAZkifgw3jusIL94p+yVwCO5pJysHGNx4fjniM9JYas7sqY3JOgMAo +wJPvLjf5ScdA1/3ySxRclaYarxp1UzvL387Lfn+Zc2xubdWZYXhhpndo0129GpVt +1IQ+jK2BpQKBgQDmR4Bt3xl0QJ9pVrAFM8xvDaS+GxwgFsJFetjLPvtwS/YzZe8a +PXzXZF3wwUxMfVYQduF/Wovx+3M33nXol75fmtRQYuF6ViaT/XbfhJi+NFfzCSFr +PCPDjlMA6ViuDxlr9YTxTwVSXDgHfpQ4+6fNmKpDJE+9XbA+9pNB58PToQKBgGCV +P169pCs3SFs0nTTkgwIYey2VO07wpWTGZWl5TqqhgKNlKufBQPoq7mI1X1LtacgM +jcxw1npWTGzBSyIX1x+ZdQHm+roPJ4Kwg1hsAQV6T3PbuORKete6Zu+HZDLNHMjh +aijcp/Voptv+z4uoUp36GRsKERML5sdcPCjZB4OhAoGBAPtz3qT9LKVKBmC9JFuw +FI6ODFc2yFfMMGHfVdrD/shPEvMDkJb+T7er+tDxKoyKUXVKTsXA9aGRK0vE2tCI +JW2AJMS0mwxTY64FPyXUt2TgCAWUmV10+mY7cErx5duo40tnr9oXpR1l/++n++hs +aGB7T46lq/1rD0r1CI/1R0E1 +-----END PRIVATE KEY----- +`; + +export const testClientCertPem = `-----BEGIN CERTIFICATE----- +MIIDMTCCAhmgAwIBAgIURndtrmjoLJwmOabSoGqQpr/KpMAwDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRbm9kZS13cmVxIFRlc3QgQ0EwHhcNMjYwNDEzMDE1NjQ4 +WhcNMzYwNDEwMDE1NjQ4WjAgMR4wHAYDVQQDDBVub2RlLXdyZXEgVGVzdCBDbGll +bnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0Ve81P4ebnJSF+oUk +ptNieHlNbEnIOg3H2CCXsUIruC5KfxqiQ1d1SqTHlyrLVK8OZiC6jcaCgsuccUXg +L5lr1HY7iSZXKco47xHhnG93sZdSo1tF33yr8Zs+9o29AL29NIUVC6/eo+gm0v2w +6HcopBBvpmriPL5EjJ9NP6FWydM0bsttW/9a84M1ISYel6AcBlIZkDjScri4o61y +Pk9HCS5VEU1Bx/PIOJmeHWizl6g8LHBw0TGj7ZyRCroUU8TAZIocMDS9H+DgMI1d +8VhMzFDoHWAIHjYPa9LVzgmH/gTTrmkHvjzAyh8QtNUDSU8+emRzQtv9EsbWKZQH +DcT3AgMBAAGjZzBlMBMGA1UdJQQMMAoGCCsGAQUFBwMCMA4GA1UdDwEB/wQEAwIF +oDAdBgNVHQ4EFgQUeH9D/oTKLwBK9TA/rpbkYMBWI3MwHwYDVR0jBBgwFoAUD5J+ ++Bp6XLCifvvJnObeL+D8gtUwDQYJKoZIhvcNAQELBQADggEBAAzFk3GNgAdPU9IZ +zqTwcXqHT5xELdfYaRUiKF4ZMiqIyqg9BpglZRhA1Ovz6MexXV2b+EnWTkGxeUld +5bNjJBhh9CUBo04hyVU6cxgYSdqe1SXvSpF1RzXwrH/DEa8FrHT6EOyCe9Uh9IQ0 +f0VDHaZlyYWC3Yn8u2xMZfnXotrt9Z3zScuJIdDUIV5thRIy2oEGjgMWbUj2Tp80 +U97DFlYkga4VTQ+rfYvO1Ci8+l+rQC0KXZ3Vvtr4eFOtwYP/4P2hPIGjFI64Hg2o +ThSpJSCzmToJck/HTpgFkhcpblDVJrln28gwLY2J5wJI1Vh2ADpcuSviOJ3IUoW1 +ZH0yO04= +-----END CERTIFICATE----- +`; + +export const testClientKeyPem = `-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC0Ve81P4ebnJSF ++oUkptNieHlNbEnIOg3H2CCXsUIruC5KfxqiQ1d1SqTHlyrLVK8OZiC6jcaCgsuc +cUXgL5lr1HY7iSZXKco47xHhnG93sZdSo1tF33yr8Zs+9o29AL29NIUVC6/eo+gm +0v2w6HcopBBvpmriPL5EjJ9NP6FWydM0bsttW/9a84M1ISYel6AcBlIZkDjScri4 +o61yPk9HCS5VEU1Bx/PIOJmeHWizl6g8LHBw0TGj7ZyRCroUU8TAZIocMDS9H+Dg +MI1d8VhMzFDoHWAIHjYPa9LVzgmH/gTTrmkHvjzAyh8QtNUDSU8+emRzQtv9EsbW +KZQHDcT3AgMBAAECggEATmNSVROV90LjXq+sg2eO2C1lEQ4CrmnpxpZAaJ/REx7+ +5DwG0ES0dhQpt2aS4AqiA4DKNj1dbiq7pfeo94/JQVF7yVpPE7FjKUdmPusYmu9T +x0Sh7qf8UeN2Z7KVnvP9BgsX1DOd0Ynu5j1tsCIEdeKHEHlG2ausyzBTKKmBkK7y +IYl5VzXntk2PbZD59LtMTM6t6yXicMbfsAuUeLCKMgs83KRcPFMBpuA7qyI9Bmuw +PcYIq1xkDro6ARCbxGqyI3cv8BLaybiOl1mYhwVjwUTqple7OPq1mbvTBnteXUqa +SkdtD4xmEB90H91FHwbHLuDzXpgXaOAx2mkrXgGoRQKBgQD7pJ7fse2vRp0o7Gdj +BB4ECA7o+vMeDMFUHXwD5AERLKx7s7CdRsmlqYqWDMBFkp8dvxWCRFHXA6CY0LpW +JrjrHclGma3pT7OU2RDiYHoiIirohx+n53r4A2UfZrTVXnFSiVhTQVP+1f4xjt6k +f0+Wtn2DU1EsEU6ejN4h1k4YfQKBgQC3dUB/v/wcIJC7EQLFm9dHc7+G1IXJ4A+K +D+Y5JZev7a2G2q6T/+ghpbAbfH+MmKG+kK1tE4BTRQ4Nynjd2Kvg/TV8EV4aaRMz ++gB7U0AlG+HdB2pufbXciT0jX/Gf4qNi6PwUKGFuANFO8sUKArmbAUao9HRr2ETx +oU2D0fXBgwKBgQCscAFCjp2uZzgYq+6oxlB/OLpm4lgotlrgdhfeXqZwi2lilx6m +l7RkZgqGihRTWIbajm+BxalDKsQpE5cso/pBezbKv1KSN0B69kgAqFXo//rhPn7t +wszcGQ95dhriv4XuJhm1a2XQkflPInTkyizSvRjDHhvYk7+JHES3cTAwzQKBgQCm +/sxffVr4H4yfO7TSPDGSkQJesUW3pYV/n0lp65ZQRoIWpykS/3dGaZQM8R8J1EYn +OXskNwQwyEMquoubJYgPnW36KbUHRW59eazGldll7iODFyUCvtu0jBhjAwrnB17C +wmHz124YvBXLT6Gcoy5gsqCnWx4+rPbVHId63rxeEQKBgQCoJ4sbUJ86BqLlL3iI +uEChTBWu3zqX9aIg32FiSKFwbWYcqhvNolP8nFeQjS6+5qOHe9e4UsKOyoZaxzjP +MvtRJZ+2bQ7MqmggICQqkjrjAFYmznMmDV32joC79w5jGaCVetjBBZuca3zfdTNl +Anr700d9tHH03AgPyaz6DP1VbA== +-----END PRIVATE KEY----- +`; + +export const testClientIdentityPassphrase = 'node-wreq-test'; + +export const testClientIdentityPfxBase64 = + 'MIIKFwIBAzCCCcUGCSqGSIb3DQEHAaCCCbYEggmyMIIJrjCCBBoGCSqGSIb3DQEHBqCCBAswggQHAgEAMIIEAAYJKoZIhvcNAQcBMF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBBEllsCvizssnBcI0K0cznHAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQlpGixqE+qvOqoCPivIVA2oCCA5AGAtwAx6ATjxQ6h0kUE++yPV571jjAC3qLmUXUdFcRugPJwyqihGsUEZaTAa0NtuwFMUVURvlP21HwSs5TKIHpxYadZMHBaOIN3hkJO3opVBMFOOlm0z/YKTqU6ncM21JoXF+uJDChmFOSyiPMg9GKiBxPkSjtkbsSMcuBUXbvbRUa6+zOzDdz1hpzCFc71C2O8zZY2DcrrP4nP/iNY2OZ7geRaDbpJ83f6jZAMIbWkqF5H7nFzTr4XLIbwSAzVpSzmTK2JmESUK2+Ik85T8rQSe5fjSlEukdaqqVLBlOCFrSItUSCiUp112yEsuYRwQLOpmCPx0cn7rYTt/QsXoh57j5bVVwJVxdij1sJwWgUhxzvQI4ufwLQMuKZ/XHJR9uD/9fCv5nt+OXyDelc4i+JT2wqivd+5TweobFJVcCPyEd4t9tl20ppABJo5LC2oFiosO1AXAXMmXMuz6O5VzUa3bPnDdHF4V9XT8vW7zy/hPZXkkljZqPjiPO7ec4JVmgzi8OeRA1DyBBOw0cMfP4Zu+BlReNskFU3HQugJ88GOV7hlViqhZeRXLTYpYoqzA1YHPUNNijykPjCFd54L2EAkyZetb/ws2yeyJ+xdwmgBiMSc3BeU3UKGj59yjIfkvNUEQJ8V4pMcBAQ5VBFchu0Zg0DRWkVmv+Z6JkKZaO61zeiwlNQpvVdOoJ1xb4B/Zw688Yr3/mmZrXVG3Uuy4T1YA4NOP+x6hYONEDMw2w5WiY1Nk8nBTvJTOkGM33DB49f7PKKz2seDsduzA/RK1Ru0Sa3UglmMFsJUOPPaF8BnjYZMOwl0LtaM15+iqczcIL0dfRAnD8Yqkm1yG+x9/SZ5lKOUNgl1HRDT1jub2MY1R3xJk43qzAdtf1gIyf2cJeCN7bZ6usOVPzO1RIKV0cIlirCoYjEHDp6yrp60eK3CLsV8Yei37ZE/O9G+Q3jjbCivzvBXPZXavzQ/msQ/QvMuyK/tHeRcbWp1jPZhKZo2x3VM2ofaidaKuf516hIuYwGGBxpgelIfWJvxogpjduNLyQd5+PmDbSPya2a4KaCWmd0jw/OpDzlkvCXQA5KQcLSydbVacLoauR0yHIzmYUwqW2w+65jI+HPJ/tcspbwGWHAeVKW70bFgSG7+WnWt5e4sT/jtbP2Nq8LQHobuVvKeMmmk7IEoMB1LQvTYm/bXdMaCEdPJW1P9lA0c1MlnI4wggWMBgkqhkiG9w0BBwGgggV9BIIFeTCCBXUwggVxBgsqhkiG9w0BDAoBAqCCBTkwggU1MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBBx0R22lDR1CC8imPOnm8qiAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQGNL527n6xqiCy3rpNEOgFASCBNDzJkhGfkNjfY43cAJfG6thv65nS3ZF2kOmve2lhu2y+N+73+vsuuWoJuvXhDPF+pgi1n5wQGq18cOcSovTK8m8lOm1ISjrJ5XcP/UKArZOswGGQqJUrsJJITB38Ng0pWBuRJI3tN8w9kLurLXc4PUyb9A3PzemIbzcTu/iGZjpkEq11rxQgaWS/iv+XkeMi/boDoq25e1t4yaCDbe5/fHChpqwhuFQCSWrDPIClzJhOi3arjnu7mY5Qoq5zRT8uH8Glt/xVhvOJAr0LTHFnMp26m9m/quHhdKP7AkGhTaCPvaaVw/IN/qI+n91vSDUo7tpf0pWO/a1FCoNFg8yf6sEHm5xUvsGGCycWjpMqZGzYsM5fYu6IJq1HdJlWSHbJjqvWLUCYdp+UK1eXaLgzThK83lId09JdGOA23+iuuK4zHYpNvY1JvNo2yAwCGSPQQfJ0tnq0eLLSoXCSdoCduvUqlalFG9zY53Dq5Nyg4nfIzrEN2Te4k5oAnHZEFQOgnF16SVcRJOuxJNGiHd4B+7mVcBxzn6ZG/+gZuSNSODL9T04HQgxab11pNuUoW8+YVvI//F+n0UOFLIU+hSVz480h2RlUaGgBiHtaW1VLvgKgklfChaJSMxbTt+Mvz15NsaoqWsGLSJh7V9KJU29zQTO7jz+VPUdJv55IKL88h91cI60h2lFMRTJHPMmhumOguqRj5rXaPRozMWYgPoDhhP4O6mB3BbhwofIISiGkcrzVOhIGaUAWFJRcOFEOIkzGPfX09K1me7lVAylJsN6gg6fI8GFNZDXQn318QJVUjnc4Dm9BobxYDIFKhZovVX85Xo9LZ9WuUEdvSILghoL8cBxbjp/Ez8/vnj3wWUZ9lXxUK2T3R6OQ1mU2a47fPEcxOAUrvRBdSmE2NaoP+8KG1ny/+/YVoChUOSTLrJRy5Jcm7KPnhXi1g2UUOjuUE5IuBqKNCvspoXcVH+SeLIMLQorGOdY3kRQ8INus0fx9CMgs9s4zK7/MWVt+8cpkszvn3OK/TN2qp5x3ZjAjxdpeNbBp1VoI4xFxu4LVKgUDbQOrxPh0Sp5B3BbN+UVclo8wt4M6228ocNJ0g82YUOCiirb6+0MWUmKQSIi0UCiGXhwc0C7FrHC5rIQY/djm/Y1qOU6sInC34VXf0H+ctwXA1EKqoGYAIe33AEK/T3UPHnwBwOWK1OIsjw5Bj+itzTNnLWdQmm1QMfC5A68OIt6I7bEy+J+/VPPOId6azOJBAhifNztUw1S2LTTFbechbv7f7oE5xAku5GqqXifk3P2A5+D/tyCTZXzmBfyNY/1jKCPhhZAZaVjAbK3k+qjC1Vx8O6segS7uGsn/JqsgVbUEYBzghQ2sDERKBdUT4IauiCDniUGGdM86cypJfuRizDz+Niym4CbS/IHeHUb1DKt5wKOkmgfHK/0zCS4I3bBkolZtfg/N9JZCQg4o8u9kbwAhi4IFWGMfyXXot0zG1E1118BAHDd9V77VKPLVtBReQ8UT0Q4a5DjnElmDE257yFhYZHt1g5dBI3hDs4rzrlh+HlFK80MdVc8a7AYsSFY6f1EnYU7LM42AwDMEWfDePh67x+AghYtxywo4IEYmp3EsU1zr/dZaZ/DfuLWAFXQF6Y68jElMCMGCSqGSIb3DQEJFTEWBBQZ/8R+h+lv+lJmURx33hbxu/eg6TBJMDEwDQYJYIZIAWUDBAIBBQAEIABnfDX65iTlvef4EqjdAFF/HBZBNN0oGBK8K41lGbtHBBArqOa3Ib/xTKE0En0c7b8CAgIIAA=='; diff --git a/src/test/helpers/local-server.ts b/src/test/helpers/local-server.ts index 2738b3c..2fe355f 100644 --- a/src/test/helpers/local-server.ts +++ b/src/test/helpers/local-server.ts @@ -2,6 +2,9 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import { after, before } from 'node:test'; import { WebSocketServer, type WebSocket as WsPeer } from 'ws'; +const WINDOWS_1251_BODY = Buffer.from('cff0e8e2e5f22c20ece8f021', 'hex'); +const ZSTD_RESPONSE_BODY = Buffer.from('KLUv/QRYgQAAenN0ZCByZXNwb25zZSBva4lnadQ=', 'base64'); + export function onceEvent(target: EventTarget, type: string): Promise { return new Promise((resolve) => { const listener = (event: Event) => { @@ -41,6 +44,16 @@ export function setupLocalTestServer() { response.end(JSON.stringify(body)); } + async function readRequestBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + return Buffer.concat(chunks); + } + before(async () => { wsServer = new WebSocketServer({ noServer: true, @@ -78,105 +91,150 @@ export function setupLocalTestServer() { }); localServer = createServer((request, response) => { - const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + void (async () => { + const url = new URL(request.url ?? '/', 'http://127.0.0.1'); - if (url.pathname === '/retry') { - const key = url.searchParams.get('key') ?? 'default'; - const failCount = Number(url.searchParams.get('failCount') ?? '0'); - const count = (retryAttempts.get(key) ?? 0) + 1; + if (url.pathname === '/retry') { + const key = url.searchParams.get('key') ?? 'default'; + const failCount = Number(url.searchParams.get('failCount') ?? '0'); + const count = (retryAttempts.get(key) ?? 0) + 1; - retryAttempts.set(key, count); + retryAttempts.set(key, count); - if (count <= failCount) { - sendJson(response, 503, { attempt: count, retried: false }); + if (count <= failCount) { + sendJson(response, 503, { attempt: count, retried: false }); + + return; + } + + sendJson(response, 200, { attempt: count, retried: count > 1 }); return; } - sendJson(response, 200, { attempt: count, retried: count > 1 }); + if (url.pathname === '/timings/delay') { + setTimeout(() => { + sendJson(response, 200, { delayed: true }); + }, 50); - return; - } + return; + } - if (url.pathname === '/timings/delay') { - setTimeout(() => { - sendJson(response, 200, { delayed: true }); - }, 50); + if (url.pathname === '/cookies/set') { + sendJson( + response, + 200, + { stored: true }, + { + 'set-cookie': 'session=abc123', + } + ); - return; - } + return; + } - if (url.pathname === '/cookies/set') { - sendJson( - response, - 200, - { stored: true }, - { - 'set-cookie': 'session=abc123', - } - ); + if (url.pathname === '/cookies/set-multiple') { + sendJson( + response, + 200, + { stored: true }, + { + 'set-cookie': ['session=abc123; Path=/', 'csrf=token123; Path=/'], + } + ); - return; - } + return; + } - if (url.pathname === '/cookies/set-multiple') { - sendJson( - response, - 200, - { stored: true }, - { - 'set-cookie': ['session=abc123; Path=/', 'csrf=token123; Path=/'], - } - ); + if (url.pathname === '/cookies/echo') { + sendJson(response, 200, { cookie: readCookieHeader(request) }); - return; - } + return; + } - if (url.pathname === '/cookies/echo') { - sendJson(response, 200, { cookie: readCookieHeader(request) }); + if (url.pathname === '/headers/raw') { + sendJson(response, 200, { + rawHeaders: request.rawHeaders, + headers: request.headers, + }); - return; - } + return; + } - if (url.pathname === '/headers/raw') { - sendJson(response, 200, { - rawHeaders: request.rawHeaders, - headers: request.headers, - }); + if (url.pathname === '/body/echo') { + const body = await readRequestBody(request); - return; - } + sendJson(response, 200, { + method: request.method, + headers: request.headers, + body: body.toString('utf8'), + bodyBase64: body.toString('base64'), + }); - if (url.pathname === '/redirect/start') { - response.writeHead(302, { - location: '/redirect/final', - 'set-cookie': 'redirect_session=1; Path=/', - }); - response.end(); + return; + } - return; - } + if (url.pathname === '/charset/windows-1251') { + response.writeHead(200, { + 'content-type': 'text/plain; charset=windows-1251', + 'content-length': String(WINDOWS_1251_BODY.length), + }); + response.end(WINDOWS_1251_BODY); - if (url.pathname === '/redirect/post-start') { - response.writeHead(302, { - location: '/redirect/final', - }); - response.end(); + return; + } - return; - } + if (url.pathname === '/compress/zstd') { + response.writeHead(200, { + 'content-type': 'text/plain; charset=utf-8', + 'content-encoding': 'zstd', + 'content-length': String(ZSTD_RESPONSE_BODY.length), + }); + response.end(ZSTD_RESPONSE_BODY); - if (url.pathname === '/redirect/final') { - sendJson(response, 200, { - method: request.method, - cookie: readCookieHeader(request), - hookHeader: request.headers['x-redirect-hook'] ?? '', - }); + return; + } - return; - } + if (url.pathname === '/redirect/start') { + response.writeHead(302, { + location: '/redirect/final', + 'set-cookie': 'redirect_session=1; Path=/', + }); + response.end(); + + return; + } + + if (url.pathname === '/redirect/post-start') { + response.writeHead(302, { + location: '/redirect/final', + }); + response.end(); + + return; + } + + if (url.pathname === '/redirect/final') { + sendJson(response, 200, { + method: request.method, + cookie: readCookieHeader(request), + hookHeader: request.headers['x-redirect-hook'] ?? '', + }); + + return; + } - sendJson(response, 404, { path: url.pathname }); + sendJson(response, 404, { path: url.pathname }); + })().catch((error: unknown) => { + response.writeHead(500, { + 'content-type': 'application/json', + }); + response.end( + JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + }) + ); + }); }); localServer.on('upgrade', (request, socket, head) => { diff --git a/src/test/helpers/mtls-server.ts b/src/test/helpers/mtls-server.ts new file mode 100644 index 0000000..2a7e129 --- /dev/null +++ b/src/test/helpers/mtls-server.ts @@ -0,0 +1,79 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { createServer, type Server } from 'node:https'; +import { after, before } from 'node:test'; +import type { TLSSocket } from 'node:tls'; +import { testCaPem, testServerCertPem, testServerKeyPem } from '../fixtures/mtls'; + +function sendJson(response: ServerResponse, status: number, body: unknown) { + response.writeHead(status, { + 'content-type': 'application/json', + }); + response.end(JSON.stringify(body)); +} + +export function setupMtlsTestServer() { + let baseUrl = ''; + let server: Server | undefined; + + before(async () => { + server = createServer( + { + key: testServerKeyPem, + cert: testServerCertPem, + ca: testCaPem, + requestCert: true, + rejectUnauthorized: true, + }, + (request: IncomingMessage, response: ServerResponse) => { + const url = new URL(request.url ?? '/', 'https://localhost'); + + if (url.pathname === '/protected') { + const socket = request.socket as TLSSocket; + const peerCertificate = socket.getPeerCertificate(); + + sendJson(response, 200, { + authorized: socket.authorized, + subject: peerCertificate.subject?.CN ?? null, + }); + + return; + } + + sendJson(response, 404, { path: url.pathname }); + } + ); + + await new Promise((resolve) => { + server?.listen(0, '127.0.0.1', () => { + const address = server?.address(); + + if (!address || typeof address === 'string') { + throw new Error('Failed to bind mTLS test server'); + } + + baseUrl = `https://localhost:${address.port}`; + resolve(); + }); + }); + }); + + after(async () => { + await new Promise((resolve, reject) => { + server?.close((error) => { + if (error) { + reject(error); + + return; + } + + resolve(); + }); + }); + }); + + return { + getBaseUrl() { + return baseUrl; + }, + }; +} diff --git a/src/test/helpers/proxy-server.ts b/src/test/helpers/proxy-server.ts new file mode 100644 index 0000000..cdee512 --- /dev/null +++ b/src/test/helpers/proxy-server.ts @@ -0,0 +1,74 @@ +import { request as httpRequest, createServer, type Server } from 'node:http'; +import { after, before } from 'node:test'; + +export function setupProxyTestServer() { + let proxyBaseUrl = ''; + let proxyServer: Server | undefined; + let proxiedRequests = 0; + + before(async () => { + proxyServer = createServer((request, response) => { + proxiedRequests += 1; + + const targetUrl = new URL(request.url ?? '/'); + const upstream = httpRequest( + targetUrl, + { + method: request.method, + headers: request.headers, + }, + (upstreamResponse) => { + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(response); + } + ); + + request.pipe(upstream); + upstream.on('error', (error: Error) => { + response.writeHead(502, { + 'content-type': 'application/json', + }); + response.end(JSON.stringify({ error: error.message })); + }); + }); + + await new Promise((resolve) => { + proxyServer?.listen(0, '127.0.0.1', () => { + const address = proxyServer?.address(); + + if (!address || typeof address === 'string') { + throw new Error('Failed to bind proxy test server'); + } + + proxyBaseUrl = `http://127.0.0.1:${address.port}`; + resolve(); + }); + }); + }); + + after(async () => { + await new Promise((resolve, reject) => { + proxyServer?.close((error) => { + if (error) { + reject(error); + + return; + } + + resolve(); + }); + }); + }); + + return { + getBaseUrl() { + return proxyBaseUrl; + }, + getHits() { + return proxiedRequests; + }, + resetHits() { + proxiedRequests = 0; + }, + }; +} diff --git a/src/test/http-client.spec.ts b/src/test/http-client.spec.ts index 99ffa8e..a2d9ad3 100644 --- a/src/test/http-client.spec.ts +++ b/src/test/http-client.spec.ts @@ -101,11 +101,10 @@ describe('http client', () => { ); }); - test('should preserve ordered header tuples and original header names when requested', async () => { + test('should preserve ordered header tuples and original header names', async () => { const response = await fetch(`${getBaseUrl()}/headers/raw`, { browser: 'chrome_137', disableDefaultHeaders: true, - keepOriginalHeaderNames: true, headers: [ ['x-lower', 'one'], ['X-Mixed', 'two'], diff --git a/src/test/mtls.spec.ts b/src/test/mtls.spec.ts new file mode 100644 index 0000000..24d1e32 --- /dev/null +++ b/src/test/mtls.spec.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert'; +import { Buffer } from 'node:buffer'; +import { test } from 'node:test'; +import { fetch } from '../node-wreq'; +import { + testCaPem, + testClientCertPem, + testClientIdentityPassphrase, + testClientIdentityPfxBase64, + testClientKeyPem, +} from './fixtures/mtls'; +import { setupMtlsTestServer } from './helpers/mtls-server'; + +const { getBaseUrl } = setupMtlsTestServer(); + +test('should authenticate with a PEM client certificate for mTLS', async () => { + const response = await fetch(`${getBaseUrl()}/protected`, { + browser: 'chrome_137', + tlsIdentity: { + cert: testClientCertPem, + key: testClientKeyPem, + }, + ca: { + cert: testCaPem, + includeDefaultRoots: false, + }, + }); + + assert.strictEqual(response.status, 200); + + const body = await response.json<{ authorized: boolean; subject: string | null }>(); + + assert.strictEqual(body.authorized, true); + assert.strictEqual(body.subject, 'node-wreq Test Client'); +}); + +test('should authenticate with a PKCS#12 client certificate for mTLS', async () => { + const response = await fetch(`${getBaseUrl()}/protected`, { + browser: 'chrome_137', + tlsIdentity: { + pfx: Buffer.from(testClientIdentityPfxBase64, 'base64'), + passphrase: testClientIdentityPassphrase, + }, + ca: { + cert: testCaPem, + includeDefaultRoots: false, + }, + }); + + assert.strictEqual(response.status, 200); +}); + +test('should reject requests to an mTLS endpoint without a client certificate', async () => { + await assert.rejects(async () => { + await fetch(`${getBaseUrl()}/protected`, { + browser: 'chrome_137', + ca: { + cert: testCaPem, + includeDefaultRoots: false, + }, + }); + }); +}); diff --git a/src/test/node-wreq.spec.ts b/src/test/node-wreq.spec.ts index b9976b4..e71456f 100644 --- a/src/test/node-wreq.spec.ts +++ b/src/test/node-wreq.spec.ts @@ -1,5 +1,7 @@ import './cookies-redirects.spec'; import './hooks-retries.spec'; import './http-client.spec'; +import './mtls.spec'; import './response.spec'; +import './transport-features.spec'; import './websocket.spec'; diff --git a/src/test/transport-features.spec.ts b/src/test/transport-features.spec.ts new file mode 100644 index 0000000..0c197bf --- /dev/null +++ b/src/test/transport-features.spec.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert'; +import { Buffer } from 'node:buffer'; +import { describe, test } from 'node:test'; +import { fetch } from '../node-wreq'; +import { setupLocalTestServer } from './helpers/local-server'; +import { setupProxyTestServer } from './helpers/proxy-server'; + +describe('transport features', () => { + const { getBaseUrl } = setupLocalTestServer(); + const proxyServer = setupProxyTestServer(); + + test('should upload multipart FormData bodies like fetch', async () => { + const formData = new FormData(); + + formData.append('alpha', '1'); + formData.append('beta', 'two'); + formData.append( + 'upload', + new File([Buffer.from('hello multipart')], 'hello.txt', { type: 'text/plain' }) + ); + + const response = await fetch(`${getBaseUrl()}/body/echo`, { + method: 'POST', + body: formData, + }); + const body = await response.json<{ body: string; headers: Record }>(); + + assert.match( + body.headers['content-type'], + /^multipart\/form-data; boundary=/, + 'multipart bodies should set a valid content-type boundary' + ); + assert.ok(body.body.includes('name="alpha"'), 'multipart payload should include text fields'); + assert.ok(body.body.includes('name="beta"'), 'multipart payload should include all fields'); + assert.ok( + body.body.includes('filename="hello.txt"'), + 'multipart payload should preserve filenames' + ); + assert.ok( + body.body.includes('hello multipart'), + 'multipart payload should include file contents' + ); + }); + + test('should decode response.text() using the declared charset', async () => { + const response = await fetch(`${getBaseUrl()}/charset/windows-1251`); + + assert.strictEqual(await response.text(), 'Привет, мир!'); + }); + + test('should transparently decompress zstd responses when compression is enabled', async () => { + const response = await fetch(`${getBaseUrl()}/compress/zstd`); + + assert.strictEqual(await response.text(), 'zstd response ok'); + assert.strictEqual( + response.headers.get('content-encoding'), + null, + 'decompressed responses should not expose stale content-encoding headers' + ); + }); + + test('should support per-request DNS host overrides', async () => { + const target = new URL(`${getBaseUrl()}/headers/raw`); + + target.hostname = 'example.test'; + + const response = await fetch(target, { + dns: { + hosts: { + 'example.test': '127.0.0.1', + }, + }, + }); + const body = await response.json<{ headers: Record }>(); + + assert.strictEqual(response.status, 200); + assert.strictEqual(body.headers.host, `example.test:${target.port}`); + }); + + test('should honor env/system proxy by default and allow opting out with proxy=false', async () => { + const previous = { + HTTP_PROXY: process.env.HTTP_PROXY, + http_proxy: process.env.http_proxy, + HTTPS_PROXY: process.env.HTTPS_PROXY, + https_proxy: process.env.https_proxy, + ALL_PROXY: process.env.ALL_PROXY, + all_proxy: process.env.all_proxy, + NO_PROXY: process.env.NO_PROXY, + no_proxy: process.env.no_proxy, + }; + + try { + process.env.HTTP_PROXY = proxyServer.getBaseUrl(); + process.env.http_proxy = proxyServer.getBaseUrl(); + delete process.env.HTTPS_PROXY; + delete process.env.https_proxy; + delete process.env.ALL_PROXY; + delete process.env.all_proxy; + delete process.env.NO_PROXY; + delete process.env.no_proxy; + + proxyServer.resetHits(); + + const proxiedResponse = await fetch(`${getBaseUrl()}/headers/raw`); + + assert.strictEqual(proxiedResponse.status, 200); + assert.ok(proxyServer.getHits() > 0, 'requests should use env/system proxy by default'); + + proxyServer.resetHits(); + + const directResponse = await fetch(`${getBaseUrl()}/headers/raw`, { + proxy: false, + }); + + assert.strictEqual(directResponse.status, 200); + assert.strictEqual(proxyServer.getHits(), 0, 'proxy=false should bypass env/system proxy'); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + + continue; + } + + process.env[key] = value; + } + } + }); +}); diff --git a/src/test/websocket.spec.ts b/src/test/websocket.spec.ts index eb44835..e2d681f 100644 --- a/src/test/websocket.spec.ts +++ b/src/test/websocket.spec.ts @@ -135,11 +135,10 @@ describe('websocket', () => { await closePromise; }); - test('should preserve handshake header names and expose bufferedAmount', async () => { + test('should preserve handshake header order and expose bufferedAmount', async () => { const socket = await websocket(getBaseUrl().replace('http://', 'ws://') + '/ws', { browser: 'chrome_137', disableDefaultHeaders: true, - keepOriginalHeaderNames: true, headers: [ ['x-lower', 'one'], ['X-Mixed', 'two'], @@ -149,10 +148,13 @@ describe('websocket', () => { const connectedEvent = await onceEvent(socket, 'message'); const payload = JSON.parse(String(connectedEvent.data)) as { rawHeaders: string[] }; const lowerIndex = payload.rawHeaders.indexOf('x-lower'); - const mixedIndex = payload.rawHeaders.indexOf('X-Mixed'); + const mixedIndex = Math.max( + payload.rawHeaders.indexOf('X-Mixed'), + payload.rawHeaders.indexOf('x-mixed') + ); assert.ok(lowerIndex >= 0, 'handshake should preserve lowercase header name'); - assert.ok(mixedIndex >= 0, 'handshake should preserve mixed-case header name'); + assert.ok(mixedIndex >= 0, 'handshake should include the mixed-case header'); assert.ok(lowerIndex < mixedIndex, 'handshake tuple order should be preserved'); const largePayload = 'x'.repeat(256 * 1024); diff --git a/src/types/http.ts b/src/types/http.ts index e08efb8..6bae17f 100644 --- a/src/types/http.ts +++ b/src/types/http.ts @@ -5,10 +5,13 @@ import type { Hooks, HookState } from './hooks'; import type { BodyInit, BrowserProfile, + CertificateAuthority, CookieJar, + DnsOptions, HeadersInit, Http1Options, Http2Options, + TlsIdentity, HttpMethod, RequestTimings, TlsOptions, @@ -55,13 +58,13 @@ export interface RequestStats { export interface WreqInit { method?: string; headers?: HeadersInit; - keepOriginalHeaderNames?: boolean; body?: BodyInit | null; signal?: AbortSignal | null; baseURL?: string; query?: Record; browser?: BrowserProfile; - proxy?: string; + proxy?: string | false; + dns?: DnsOptions; timeout?: number; retry?: number | RetryOptions; redirect?: RedirectMode; @@ -72,6 +75,8 @@ export interface WreqInit { disableDefaultHeaders?: boolean; compress?: boolean; tlsOptions?: TlsOptions; + tlsIdentity?: TlsIdentity; + ca?: CertificateAuthority; http1Options?: Http1Options; http2Options?: Http2Options; onStats?: (stats: RequestStats) => void | Promise; @@ -95,7 +100,6 @@ export interface ResolvedOptions extends Omit< | 'throwHttpErrors' | 'disableDefaultHeaders' | 'compress' - | 'keepOriginalHeaderNames' | 'redirect' | 'maxRedirects' > { @@ -104,7 +108,6 @@ export interface ResolvedOptions extends Omit< throwHttpErrors: boolean; disableDefaultHeaders: boolean; compress: boolean; - keepOriginalHeaderNames: boolean; redirect: RedirectMode; maxRedirects: number; } diff --git a/src/types/native.ts b/src/types/native.ts index 8f6f7e8..d604cf1 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -1,17 +1,43 @@ import type { BrowserProfile, HeaderTuple, HttpMethod, RequestTimings } from './shared'; +export interface NativeDnsOptions { + servers?: string[]; + hosts?: Record; +} + +export interface NativeTlsIdentityPem { + cert: Buffer; + key: Buffer; +} + +export interface NativeTlsIdentityPfx { + pfx: Buffer; + passphrase?: string; +} + +export type NativeTlsIdentity = NativeTlsIdentityPem | NativeTlsIdentityPfx; + +export interface NativeCertificateAuthority { + certs: Buffer[]; + includeDefaultRoots: boolean; +} + export interface NativeRequestOptions { url: string; method: HttpMethod; headers: HeaderTuple[]; origHeaders?: string[]; - body?: string; + body?: Buffer; browser?: BrowserProfile; emulationJson?: string; proxy?: string; + disableSystemProxy?: boolean; + dns?: NativeDnsOptions; timeout?: number; disableDefaultHeaders?: boolean; compress?: boolean; + tlsIdentity?: NativeTlsIdentity; + ca?: NativeCertificateAuthority; } export interface NativeResponse { diff --git a/src/types/shared.ts b/src/types/shared.ts index 53d04fc..d4d8982 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -3,13 +3,20 @@ export type { BrowserProfile } from '../config/generated/browser-profiles'; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'; export type HeaderTuple = [string, string]; +export type TlsBinaryInput = Buffer | ArrayBuffer | ArrayBufferView; +export type TlsDataInput = string | TlsBinaryInput; export type HeadersInit = | Record | HeaderTuple[] | Iterable; -export type BodyInit = string | URLSearchParams | Buffer | ArrayBuffer | ArrayBufferView; +export type BodyInit = string | URLSearchParams | FormData | Buffer | ArrayBuffer | ArrayBufferView; + +export interface DnsOptions { + servers?: string | string[]; + hosts?: Record; +} export type AlpnProtocol = 'HTTP1' | 'HTTP2' | 'HTTP3'; export type AlpsProtocol = 'HTTP1' | 'HTTP2' | 'HTTP3'; @@ -70,6 +77,23 @@ export interface TlsOptions { randomAesHwOverride?: boolean; } +export interface TlsIdentityPem { + cert: TlsDataInput; + key: TlsDataInput; +} + +export interface TlsIdentityPfx { + pfx: TlsBinaryInput; + passphrase?: string; +} + +export type TlsIdentity = TlsIdentityPem | TlsIdentityPfx; + +export interface CertificateAuthority { + cert: TlsDataInput | TlsDataInput[]; + includeDefaultRoots?: boolean; +} + export interface Http1Options { http09Responses?: boolean; writev?: boolean; diff --git a/src/types/websocket.ts b/src/types/websocket.ts index e2685a3..9360111 100644 --- a/src/types/websocket.ts +++ b/src/types/websocket.ts @@ -1,9 +1,12 @@ import type { BrowserProfile, + CertificateAuthority, CookieJar, + DnsOptions, HeadersInit, Http1Options, Http2Options, + TlsIdentity, TlsOptions, } from './shared'; import type { HeaderTuple } from './shared'; @@ -12,15 +15,17 @@ export type WebSocketBinaryType = 'blob' | 'arraybuffer'; export interface WebSocketInit { headers?: HeadersInit; - keepOriginalHeaderNames?: boolean; baseURL?: string; query?: Record; browser?: BrowserProfile; - proxy?: string; + proxy?: string | false; + dns?: DnsOptions; timeout?: number; cookieJar?: CookieJar; disableDefaultHeaders?: boolean; tlsOptions?: TlsOptions; + tlsIdentity?: TlsIdentity; + ca?: CertificateAuthority; http1Options?: Http1Options; http2Options?: Http2Options; protocols?: string | string[]; @@ -34,8 +39,12 @@ export interface NativeWebSocketConnectOptions { browser?: BrowserProfile; emulationJson?: string; proxy?: string; + disableSystemProxy?: boolean; + dns?: import('./native').NativeDnsOptions; timeout?: number; disableDefaultHeaders?: boolean; + tlsIdentity?: import('./native').NativeTlsIdentity; + ca?: import('./native').NativeCertificateAuthority; protocols: string[]; } diff --git a/src/websocket/index.ts b/src/websocket/index.ts index dd2370d..113419c 100644 --- a/src/websocket/index.ts +++ b/src/websocket/index.ts @@ -1,4 +1,6 @@ import { serializeEmulationOptions } from '../config/emulation'; +import { normalizeDnsOptions, normalizeProxyOptions } from '../config/network'; +import { normalizeCertificateAuthority, normalizeTlsIdentity } from '../config/tls'; import { WebSocketError } from '../errors'; import { loadCookiesIntoHeaders } from '../http/pipeline/cookies'; import { @@ -220,15 +222,20 @@ export class WebSocket extends EventTarget { await loadCookiesIntoHeaders(init.cookieJar, this.url, headers); try { + const { proxy, disableSystemProxy } = normalizeProxyOptions(init.proxy); const connection = await nativeWebSocketConnect({ url: this.url, headers: headers.toTuples(), - origHeaders: init.keepOriginalHeaderNames ? headers.toOriginalNames() : undefined, + origHeaders: headers.toOriginalNames(), browser: init.browser, emulationJson: serializeEmulationOptions(init), - proxy: init.proxy, + proxy, + disableSystemProxy, + dns: normalizeDnsOptions(init.dns), timeout: init.timeout ?? DEFAULT_TIMEOUT, disableDefaultHeaders: init.disableDefaultHeaders ?? false, + tlsIdentity: normalizeTlsIdentity(init.tlsIdentity), + ca: normalizeCertificateAuthority(init.ca), protocols, }); From 974609e10f34b66511acfbaf2a031eb3cacf32e2 Mon Sep 17 00:00:00 2001 From: ruby Date: Wed, 15 Apr 2026 21:07:39 +0400 Subject: [PATCH 2/3] chore: bump deps --- package-lock.json | 338 +++++++++++++++++++++++----------------------- package.json | 8 +- 2 files changed, 174 insertions(+), 172 deletions(-) diff --git a/package-lock.json b/package-lock.json index c638e9d..db030de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,15 +20,15 @@ "devDependencies": { "@napi-rs/cli": "^2.18.0", "@stylistic/eslint-plugin": "^5.10.0", - "@types/node": "^20.0.0", + "@types/node": "^20.19.39", "@types/ws": "^8.18.1", - "oxfmt": "^0.41.0", - "oxlint": "^1.56.0", + "oxfmt": "^0.45.0", + "oxlint": "^1.60.0", "typescript": "^5.0.0", - "ws": "^8.18.3" + "ws": "^8.20.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@eslint-community/eslint-utils": { @@ -216,9 +216,9 @@ } }, "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.41.0.tgz", - "integrity": "sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.45.0.tgz", + "integrity": "sha512-A/UMxFob1fefCuMeGxQBulGfFE38g2Gm23ynr3u6b+b7fY7/ajGbNsa3ikMIkGMLJW/TRoQaMoP1kME7S+815w==", "cpu": [ "arm" ], @@ -233,9 +233,9 @@ } }, "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.41.0.tgz", - "integrity": "sha512-s0b1dxNgb2KomspFV2LfogC2XtSJB42POXF4bMCLJyvQmAGos4ZtjGPfQreToQEaY0FQFjz3030ggI36rF1q5g==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.45.0.tgz", + "integrity": "sha512-L63z4uZmHjgvvqvMJD7mwff8aSBkM0+X4uFr6l6U5t6+Qc9DCLVZWIunJ7Gm4fn4zHPdSq6FFQnhu9yqqobxIg==", "cpu": [ "arm64" ], @@ -250,9 +250,9 @@ } }, "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.41.0.tgz", - "integrity": "sha512-EGXGualADbv/ZmamE7/2DbsrYmjoPlAmHEpTL4vapLF4EfVD6fr8/uQDFnPJkUBjiSWFJZtFNsGeN1B6V3owmA==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.45.0.tgz", + "integrity": "sha512-UV34dd623FzqT+outIGndsCA/RBB+qgB3XVQhgmmJ9PJwa37NzPC9qzgKeOhPKxVk2HW+JKldQrVL54zs4Noww==", "cpu": [ "arm64" ], @@ -267,9 +267,9 @@ } }, "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.41.0.tgz", - "integrity": "sha512-WxySJEvdQQYMmyvISH3qDpTvoS0ebnIP63IMxLLWowJyPp/AAH0hdWtlo+iGNK5y3eVfa5jZguwNaQkDKWpGSw==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.45.0.tgz", + "integrity": "sha512-pMNJv0CMa1pDefVPeNbuQxibh8ITpWDFEhMC/IBB9Zlu76EbgzYwrzI4Cb11mqX2+rIYN70UTrh3z06TM59ptQ==", "cpu": [ "x64" ], @@ -284,9 +284,9 @@ } }, "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.41.0.tgz", - "integrity": "sha512-Y2kzMkv3U3oyuYaR4wTfGjOTYTXiFC/hXmG0yVASKkbh02BJkvD98Ij8bIevr45hNZ0DmZEgqiXF+9buD4yMYQ==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.45.0.tgz", + "integrity": "sha512-xTcRoxbbo61sW2+ZRPeH+vp/o9G8gkdhiVumFU+TpneiPm14c79l6GFlxPXlCE9bNWikigbsrvJw46zCVAQFfg==", "cpu": [ "x64" ], @@ -301,9 +301,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.41.0.tgz", - "integrity": "sha512-ptazDjdUyhket01IjPTT6ULS1KFuBfTUU97osTP96X5y/0oso+AgAaJzuH81oP0+XXyrWIHbRzozSAuQm4p48g==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.45.0.tgz", + "integrity": "sha512-hWL8Hdni+3U1mPFx1UtWeGp3tNb6EhBAUHRMbKUxVkOp3WwoJbpVO2bfUVbS4PfpledviXXNHSTl1veTa6FhkQ==", "cpu": [ "arm" ], @@ -318,9 +318,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.41.0.tgz", - "integrity": "sha512-UkoL2OKxFD+56bPEBcdGn+4juTW4HRv/T6w1dIDLnvKKWr6DbarB/mtHXlADKlFiJubJz8pRkttOR7qjYR6lTA==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.45.0.tgz", + "integrity": "sha512-6Blt/0OBT7vvfQpqYuYbpbFLPqSiaYpEJzUUWhinPEuADypDbtV1+LdjM0vYBNGPvnj85ex7lTerEX6JGcPt9w==", "cpu": [ "arm" ], @@ -335,9 +335,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.41.0.tgz", - "integrity": "sha512-gofu0PuumSOHYczD8p62CPY4UF6ee+rSLZJdUXkpwxg6pILiwSDBIouPskjF/5nF3A7QZTz2O9KFNkNxxFN9tA==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.45.0.tgz", + "integrity": "sha512-jLjoLfe+hGfjhA8hNBSdw85yCA8ePKq7ME4T+g6P9caQXvmt6IhE2X7iVjnVdkmYUWEzZrxlh4p6RkDmAMJY/A==", "cpu": [ "arm64" ], @@ -352,9 +352,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.41.0.tgz", - "integrity": "sha512-VfVZxL0+6RU86T8F8vKiDBa+iHsr8PAjQmKGBzSCAX70b6x+UOMFl+2dNihmKmUwqkCazCPfYjt6SuAPOeQJ3g==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.45.0.tgz", + "integrity": "sha512-XQKXZIKYJC3GQJ8FnD3iMntpw69Wd9kDDK/Xt79p6xnFYlGGxSNv2vIBvRTDg5CKByWFWWZLCRDOXoP/m6YN4g==", "cpu": [ "arm64" ], @@ -369,9 +369,9 @@ } }, "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.41.0.tgz", - "integrity": "sha512-bwzokz2eGvdfJbc0i+zXMJ4BBjQPqg13jyWpEEZDOrBCQ91r8KeY2Mi2kUeuMTZNFXju+jcAbAbpyJxRGla0eg==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.45.0.tgz", + "integrity": "sha512-+g5RiG+xOkdrCWkKodv407nTvMq4vYM18Uox2MhZBm/YoqFxxJpWKsloskFFG5NU13HGPw1wzYjjOVcyd9moCA==", "cpu": [ "ppc64" ], @@ -386,9 +386,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.41.0.tgz", - "integrity": "sha512-POLM//PCH9uqDeNDwWL3b3DkMmI3oI2cU6hwc2lnztD1o7dzrQs3R9nq555BZ6wI7t2lyhT9CS+CRaz5X0XqLA==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.45.0.tgz", + "integrity": "sha512-V7dXKoSyEbWAkkSF4JJNtF+NJZDmJoSarSoP30WCsB3X636Rehd3CvxBj49FIJxEBFWhvcUjGSHVeU8Erck1bQ==", "cpu": [ "riscv64" ], @@ -403,9 +403,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.41.0.tgz", - "integrity": "sha512-NNK7PzhFqLUwx/G12Xtm6scGv7UITvyGdAR5Y+TlqsG+essnuRWR4jRNODWRjzLZod0T3SayRbnkSIWMBov33w==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.45.0.tgz", + "integrity": "sha512-Vdelft1sAEYojVGgcODEFXSWYQYlIvoyIGWebKCuUibd1tvS1TjTx413xG2ZLuHpYj45CkN/ztMLMX6jrgqpgg==", "cpu": [ "riscv64" ], @@ -420,9 +420,9 @@ } }, "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.41.0.tgz", - "integrity": "sha512-qVf/zDC5cN9eKe4qI/O/m445er1IRl6swsSl7jHkqmOSVfknwCe5JXitYjZca+V/cNJSU/xPlC5EFMabMMFDpw==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.45.0.tgz", + "integrity": "sha512-RR7xKgNpqwENnK0aYCGYg0JycY2n93J0reNjHyes+I9Gq52dH95x+CBlnlAQHCPfz6FGnKA9HirgUl14WO6o7w==", "cpu": [ "s390x" ], @@ -437,9 +437,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.41.0.tgz", - "integrity": "sha512-ojxYWu7vUb6ysYqVCPHuAPVZHAI40gfZ0PDtZAMwVmh2f0V8ExpPIKoAKr7/8sNbAXJBBpZhs2coypIo2jJX4w==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.45.0.tgz", + "integrity": "sha512-U/QQ0+BQNSHxjuXR/utvXnQ50Vu5kUuqEomZvQ1/3mhgbBiMc2WU9q5kZ5WwLp3gnFIx9ibkveoRSe2EZubkqg==", "cpu": [ "x64" ], @@ -454,9 +454,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.41.0.tgz", - "integrity": "sha512-O2exZLBxoCMIv2vlvcbkdedazJPTdG0VSup+0QUCfYQtx751zCZNboX2ZUOiQ/gDTdhtXvSiot0h6GEGkOyalA==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.45.0.tgz", + "integrity": "sha512-o5TLOUCF0RWQjsIS06yVC+kFgp092/yLe6qBGSUvtnmTVw9gxjpdQSXc3VN5Cnive4K11HNstEZF8ROKHfDFSw==", "cpu": [ "x64" ], @@ -471,9 +471,9 @@ } }, "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.41.0.tgz", - "integrity": "sha512-N+31/VoL+z+NNBt8viy3I4NaIdPbiYeOnB884LKqvXldaE2dRztdPv3q5ipfZYv0RwFp7JfqS4I27K/DSHCakg==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.45.0.tgz", + "integrity": "sha512-RnGcV3HgPuOjsGx/k9oyRNKmOp+NBLGzZTdPDYbc19r7NGeYPplnUU/BfU35bX2Y/O4ejvHxcfkvW2WoYL/gsg==", "cpu": [ "arm64" ], @@ -488,9 +488,9 @@ } }, "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.41.0.tgz", - "integrity": "sha512-Z7NAtu/RN8kjCQ1y5oDD0nTAeRswh3GJ93qwcW51srmidP7XPBmZbLlwERu1W5veCevQJtPS9xmkpcDTYsGIwQ==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.45.0.tgz", + "integrity": "sha512-v3Vj7iKKsUFwt9w5hsqIIoErKVoENC6LoqfDlteOQ5QMDCXihlqLoxpmviUhXnNncg4zV6U9BPwlBbwa+qm4wg==", "cpu": [ "arm64" ], @@ -505,9 +505,9 @@ } }, "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.41.0.tgz", - "integrity": "sha512-uNxxP3l4bJ6VyzIeRqCmBU2Q0SkCFgIhvx9/9dJ9V8t/v+jP1IBsuaLwCXGR8JPHtkj4tFp+RHtUmU2ZYAUpMA==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.45.0.tgz", + "integrity": "sha512-N8yotPBX6ph0H3toF4AEpdCeVPrdcSetj+8eGiZGsrLsng3bs/Q5HPu4bbSxip5GBPx5hGbGHrZwH4+rcrjhHA==", "cpu": [ "ia32" ], @@ -522,9 +522,9 @@ } }, "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.41.0.tgz", - "integrity": "sha512-49ZSpbZ1noozyPapE8SUOSm3IN0Ze4b5nkO+4+7fq6oEYQQJFhE0saj5k/Gg4oewVPdjn0L3ZFeWk2Vehjcw7A==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.45.0.tgz", + "integrity": "sha512-w5MMTRCK1dpQeRA+HHqXQXyN33DlG/N2LOYxJmaT4fJjcmZrbNnqw7SmIk7I2/a2493PPLZ+2E/Ar6t2iKVMug==", "cpu": [ "x64" ], @@ -539,9 +539,9 @@ } }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.56.0.tgz", - "integrity": "sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.60.0.tgz", + "integrity": "sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA==", "cpu": [ "arm" ], @@ -556,9 +556,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.56.0.tgz", - "integrity": "sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.60.0.tgz", + "integrity": "sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw==", "cpu": [ "arm64" ], @@ -573,9 +573,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.56.0.tgz", - "integrity": "sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.60.0.tgz", + "integrity": "sha512-pJsgd9AfplLGBm1fIr25V6V14vMrayhx4uIQvlfH7jWs2SZwSrvi3TfgfJySB8T+hvyEH8K2zXljQiUnkgUnfQ==", "cpu": [ "arm64" ], @@ -590,9 +590,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.56.0.tgz", - "integrity": "sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.60.0.tgz", + "integrity": "sha512-Ue1aXHX49ivwflKqGJc7zcd/LeLgbhaTcDCQStgx5x06AXgjEAZmvrlMuIkWd4AL4FHQe6QJ9f33z04Cg448VQ==", "cpu": [ "x64" ], @@ -607,9 +607,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.56.0.tgz", - "integrity": "sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.60.0.tgz", + "integrity": "sha512-YCyQzsQtusQw+gNRW9rRTifSO+Dt/+dtCl2NHoDMZqJlRTEZ/Oht9YnuporI9yiTx7+cB+eqzX3MtHHVHGIWhg==", "cpu": [ "x64" ], @@ -624,9 +624,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.56.0.tgz", - "integrity": "sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.60.0.tgz", + "integrity": "sha512-c7dxM2Zksa45Qw16i2iGY3Fti2NirJ38FrsBsKw+qcJ0OtqTsBgKJLF0xV+yLG56UH01Z8WRPgsw31e0MoRoGQ==", "cpu": [ "arm" ], @@ -641,9 +641,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.56.0.tgz", - "integrity": "sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.60.0.tgz", + "integrity": "sha512-ZWALoA42UYqBEP1Tbw9OWURgFGS1nWj2AAvLdY6ZcGx/Gj93qVCBKjcvwXMupZibYwFbi9s/rzqkZseb/6gVtQ==", "cpu": [ "arm" ], @@ -658,9 +658,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.56.0.tgz", - "integrity": "sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.60.0.tgz", + "integrity": "sha512-tpy+1w4p9hN5CicMCxqNy6ymfRtV5ayE573vFNjp1k1TN/qhLFgflveZoE/0++RlkHikBz2vY545NWm/hp7big==", "cpu": [ "arm64" ], @@ -675,9 +675,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.56.0.tgz", - "integrity": "sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.60.0.tgz", + "integrity": "sha512-eDYDXZGhQAXyn6GwtwiX/qcLS0HlOLPJ/+iiIY8RYr+3P8oKBmgKxADLlniL6FtWfE7pPk7IGN9/xvDEvDvFeg==", "cpu": [ "arm64" ], @@ -692,9 +692,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.56.0.tgz", - "integrity": "sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.60.0.tgz", + "integrity": "sha512-nxehly5XYBHUWI9VJX1bqCf9j/B43DaK/aS/T1fcxCpX3PA4Rm9BB54nPD1CKayT8xg6REN1ao+01hSRNgy8OA==", "cpu": [ "ppc64" ], @@ -709,9 +709,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.56.0.tgz", - "integrity": "sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.60.0.tgz", + "integrity": "sha512-j1qf/NaUfOWQutjeoooNG1Q0zsK0XGmSu1uDLq3cctquRF3j7t9Hxqf/76ehCc5GEUAanth2W4Fa+XT1RFg/nw==", "cpu": [ "riscv64" ], @@ -726,9 +726,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.56.0.tgz", - "integrity": "sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.60.0.tgz", + "integrity": "sha512-YELKPRefQ/q/h3RUmeRfPCUhh2wBvgV1RyZ/F9M9u8cDyXsQW2ojv1DeWQTt466yczDITjZnIOg/s05pk7Ve2A==", "cpu": [ "riscv64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.56.0.tgz", - "integrity": "sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.60.0.tgz", + "integrity": "sha512-JkO3C6Gki7Y6h/MiIkFKvHFOz98/YWvQ4WYbK9DLXACMP2rjULzkeGyAzorJE5S1dzLQGFgeqvN779kSFwoV1g==", "cpu": [ "s390x" ], @@ -760,9 +760,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.56.0.tgz", - "integrity": "sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.60.0.tgz", + "integrity": "sha512-XjKHdFVCpZZZSWBCKyyqCq65s2AKXykMXkjLoKYODrD+f5toLhlwsMESscu8FbgnJQ4Y/dpR/zdazsahmgBJIA==", "cpu": [ "x64" ], @@ -777,9 +777,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.56.0.tgz", - "integrity": "sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.60.0.tgz", + "integrity": "sha512-js29ZWIuPhNWzY8NC7KoffEMEeWG105vbmm+8EOJsC+T/jHBiKIJEUF78+F/IrgEWMMP9N0kRND4Pp75+xAhKg==", "cpu": [ "x64" ], @@ -794,9 +794,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.56.0.tgz", - "integrity": "sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.60.0.tgz", + "integrity": "sha512-H+PUITKHk04stFpWj3x3Kg08Afp/bcXSBi0EhasR5a0Vw7StXHTzdl655PUI0fB4qdh2Wsu6Dsi+3ACxPoyQnA==", "cpu": [ "arm64" ], @@ -811,9 +811,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.56.0.tgz", - "integrity": "sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.60.0.tgz", + "integrity": "sha512-WA/yc7f7ZfCefBXVzNHn1Ztulb1EFwNBb4jMZ6pjML0zz6pHujlF3Q3jySluz3XHl/GNeMTntG1seUBWVMlMag==", "cpu": [ "arm64" ], @@ -828,9 +828,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.56.0.tgz", - "integrity": "sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.60.0.tgz", + "integrity": "sha512-33YxL1sqwYNZXtn3MD/4dno6s0xeedXOJlT1WohkVD565WvohClZUr7vwKdAk954n4xiEWJkewiCr+zLeq7AeA==", "cpu": [ "ia32" ], @@ -845,9 +845,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.56.0.tgz", - "integrity": "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.60.0.tgz", + "integrity": "sha512-JOro4ZcfBLamJCyfURQmOQByoorgOdx3ZjAkSqnb/CyG/i+lN3KoV5LAgk5ZAW6DPq7/Cx7n23f8DuTWXTWgyQ==", "cpu": [ "x64" ], @@ -907,7 +907,9 @@ "peer": true }, "node_modules/@types/node": { - "version": "20.19.21", + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "dev": true, "license": "MIT", "dependencies": { @@ -1519,9 +1521,9 @@ } }, "node_modules/oxfmt": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.41.0.tgz", - "integrity": "sha512-sKLdJZdQ3bw6x9qKiT7+eID4MNEXlDHf5ZacfIircrq6Qwjk0L6t2/JQlZZrVHTXJawK3KaMuBoJnEJPcqCEdg==", + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.45.0.tgz", + "integrity": "sha512-0o/COoN9fY50bjVeM7PQsNgbhndKurBIeTIcspW033OumksjJJmIVDKjAk5HMwU/GHTxSOdGDdhJ6BRzGPmsHg==", "dev": true, "license": "MIT", "dependencies": { @@ -1537,31 +1539,31 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.41.0", - "@oxfmt/binding-android-arm64": "0.41.0", - "@oxfmt/binding-darwin-arm64": "0.41.0", - "@oxfmt/binding-darwin-x64": "0.41.0", - "@oxfmt/binding-freebsd-x64": "0.41.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.41.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.41.0", - "@oxfmt/binding-linux-arm64-gnu": "0.41.0", - "@oxfmt/binding-linux-arm64-musl": "0.41.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.41.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.41.0", - "@oxfmt/binding-linux-riscv64-musl": "0.41.0", - "@oxfmt/binding-linux-s390x-gnu": "0.41.0", - "@oxfmt/binding-linux-x64-gnu": "0.41.0", - "@oxfmt/binding-linux-x64-musl": "0.41.0", - "@oxfmt/binding-openharmony-arm64": "0.41.0", - "@oxfmt/binding-win32-arm64-msvc": "0.41.0", - "@oxfmt/binding-win32-ia32-msvc": "0.41.0", - "@oxfmt/binding-win32-x64-msvc": "0.41.0" + "@oxfmt/binding-android-arm-eabi": "0.45.0", + "@oxfmt/binding-android-arm64": "0.45.0", + "@oxfmt/binding-darwin-arm64": "0.45.0", + "@oxfmt/binding-darwin-x64": "0.45.0", + "@oxfmt/binding-freebsd-x64": "0.45.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.45.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.45.0", + "@oxfmt/binding-linux-arm64-gnu": "0.45.0", + "@oxfmt/binding-linux-arm64-musl": "0.45.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.45.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.45.0", + "@oxfmt/binding-linux-riscv64-musl": "0.45.0", + "@oxfmt/binding-linux-s390x-gnu": "0.45.0", + "@oxfmt/binding-linux-x64-gnu": "0.45.0", + "@oxfmt/binding-linux-x64-musl": "0.45.0", + "@oxfmt/binding-openharmony-arm64": "0.45.0", + "@oxfmt/binding-win32-arm64-msvc": "0.45.0", + "@oxfmt/binding-win32-ia32-msvc": "0.45.0", + "@oxfmt/binding-win32-x64-msvc": "0.45.0" } }, "node_modules/oxlint": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.56.0.tgz", - "integrity": "sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.60.0.tgz", + "integrity": "sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw==", "dev": true, "license": "MIT", "bin": { @@ -1574,28 +1576,28 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.56.0", - "@oxlint/binding-android-arm64": "1.56.0", - "@oxlint/binding-darwin-arm64": "1.56.0", - "@oxlint/binding-darwin-x64": "1.56.0", - "@oxlint/binding-freebsd-x64": "1.56.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.56.0", - "@oxlint/binding-linux-arm-musleabihf": "1.56.0", - "@oxlint/binding-linux-arm64-gnu": "1.56.0", - "@oxlint/binding-linux-arm64-musl": "1.56.0", - "@oxlint/binding-linux-ppc64-gnu": "1.56.0", - "@oxlint/binding-linux-riscv64-gnu": "1.56.0", - "@oxlint/binding-linux-riscv64-musl": "1.56.0", - "@oxlint/binding-linux-s390x-gnu": "1.56.0", - "@oxlint/binding-linux-x64-gnu": "1.56.0", - "@oxlint/binding-linux-x64-musl": "1.56.0", - "@oxlint/binding-openharmony-arm64": "1.56.0", - "@oxlint/binding-win32-arm64-msvc": "1.56.0", - "@oxlint/binding-win32-ia32-msvc": "1.56.0", - "@oxlint/binding-win32-x64-msvc": "1.56.0" + "@oxlint/binding-android-arm-eabi": "1.60.0", + "@oxlint/binding-android-arm64": "1.60.0", + "@oxlint/binding-darwin-arm64": "1.60.0", + "@oxlint/binding-darwin-x64": "1.60.0", + "@oxlint/binding-freebsd-x64": "1.60.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.60.0", + "@oxlint/binding-linux-arm-musleabihf": "1.60.0", + "@oxlint/binding-linux-arm64-gnu": "1.60.0", + "@oxlint/binding-linux-arm64-musl": "1.60.0", + "@oxlint/binding-linux-ppc64-gnu": "1.60.0", + "@oxlint/binding-linux-riscv64-gnu": "1.60.0", + "@oxlint/binding-linux-riscv64-musl": "1.60.0", + "@oxlint/binding-linux-s390x-gnu": "1.60.0", + "@oxlint/binding-linux-x64-gnu": "1.60.0", + "@oxlint/binding-linux-x64-musl": "1.60.0", + "@oxlint/binding-openharmony-arm64": "1.60.0", + "@oxlint/binding-win32-arm64-msvc": "1.60.0", + "@oxlint/binding-win32-ia32-msvc": "1.60.0", + "@oxlint/binding-win32-x64-msvc": "1.60.0" }, "peerDependencies": { - "oxlint-tsgolint": ">=0.15.0" + "oxlint-tsgolint": ">=0.18.0" }, "peerDependenciesMeta": { "oxlint-tsgolint": { @@ -1800,9 +1802,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 8f6cce6..ab07423 100644 --- a/package.json +++ b/package.json @@ -67,12 +67,12 @@ "devDependencies": { "@napi-rs/cli": "^2.18.0", "@stylistic/eslint-plugin": "^5.10.0", - "@types/node": "^20.0.0", + "@types/node": "^20.19.39", "@types/ws": "^8.18.1", - "oxfmt": "^0.41.0", - "oxlint": "^1.56.0", + "oxfmt": "^0.45.0", + "oxlint": "^1.60.0", "typescript": "^5.0.0", - "ws": "^8.18.3" + "ws": "^8.20.0" }, "engines": { "node": ">=20.0.0" From 7323f802a74e019d13e55f7fc9745c7f715e2cca Mon Sep 17 00:00:00 2001 From: ruby Date: Wed, 15 Apr 2026 22:28:46 +0400 Subject: [PATCH 3/3] ci: add dependency audit and upstream sync --- .github/dependabot.yml | 29 +++++++ .github/workflows/dependency-audit.yml | 29 +++++++ .github/workflows/wreq-upstream.yml | 73 ++++++++++++++++ package.json | 1 + scripts/update-wreq-upstream.mjs | 111 +++++++++++++++++++++++++ 5 files changed, 243 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/dependency-audit.yml create mode 100644 .github/workflows/wreq-upstream.yml create mode 100644 scripts/update-wreq-upstream.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1b5725a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,29 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: Europe/Moscow + open-pull-requests-limit: 1 + versioning-strategy: auto + labels: + - dependencies + ignore: + - dependency-name: "*" + update-types: + - version-update:semver-major + groups: + npm-minor-and-patch: + applies-to: version-updates + patterns: + - "*" + update-types: + - minor + - patch + npm-security: + applies-to: security-updates + patterns: + - "*" diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml new file mode 100644 index 0000000..bac41b6 --- /dev/null +++ b/.github/workflows/dependency-audit.yml @@ -0,0 +1,29 @@ +name: 🔐 Dependency Audit + +on: + schedule: + - cron: "0 5 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: package.json + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run npm audit + run: npm audit --audit-level=moderate diff --git a/.github/workflows/wreq-upstream.yml b/.github/workflows/wreq-upstream.yml new file mode 100644 index 0000000..6da3591 --- /dev/null +++ b/.github/workflows/wreq-upstream.yml @@ -0,0 +1,73 @@ +name: 📦 Sync wreq Upstream + +on: + schedule: + - cron: "15 5 * * 1" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: monitor-wreq-upstream + cancel-in-progress: false + +jobs: + bump-wreq: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: package.json + cache: npm + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Check crates.io for upstream wreq releases + run: node ./scripts/update-wreq-upstream.mjs + + - name: Detect dependency changes + id: changes + run: | + if git diff --quiet -- rust/Cargo.toml rust/Cargo.lock; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Install dependencies + if: steps.changes.outputs.changed == 'true' + run: npm ci + + - name: Run test suite + if: steps.changes.outputs.changed == 'true' + run: npm test + + - name: Create pull request + if: steps.changes.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v7 + with: + branch: chore/update-wreq-upstream + delete-branch: true + commit-message: "chore: bump upstream wreq crates" + title: "chore: bump upstream wreq crates" + body: | + Automated upstream bump for the Rust crates. + + This workflow checks `crates.io` for the latest published `max_version` of: + - `wreq` + - `wreq-util` + labels: | + dependencies + add-paths: | + rust/Cargo.toml + rust/Cargo.lock diff --git a/package.json b/package.json index ab07423..f756e65 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "build": "npm run build:rust && npm run build:ts", "build:rust": "napi build --platform --release --cargo-cwd rust rust", "build:ts": "node ./scripts/generate-browser-profiles.mjs && tsc && node ./scripts/postbuild.mjs", + "deps:wreq": "node ./scripts/update-wreq-upstream.mjs", "prepare": "node ./scripts/install-git-hooks.mjs", "prepare:publish:main": "node ./scripts/prepare-main-package.mjs", "prepare:publish:platform": "node ./scripts/prepare-platform-package.mjs", diff --git a/scripts/update-wreq-upstream.mjs b/scripts/update-wreq-upstream.mjs new file mode 100644 index 0000000..d369617 --- /dev/null +++ b/scripts/update-wreq-upstream.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +const manifestPath = fileURLToPath(new URL('../rust/Cargo.toml', import.meta.url)); + +const upstreamCrates = [ + { dependency: 'wreq', crate: 'wreq' }, + { dependency: 'wreq-util', crate: 'wreq-util' }, +]; + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +async function fetchLatestVersion(crate) { + const response = await fetch(`https://crates.io/api/v1/crates/${crate}`, { + headers: { + Accept: 'application/json', + 'User-Agent': 'node-wreq-upstream-monitor', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch ${crate} metadata from crates.io: ${response.status} ${response.statusText}`); + } + + const payload = await response.json(); + const latestVersion = payload?.crate?.max_version; + + if (typeof latestVersion !== 'string' || latestVersion.length === 0) { + throw new Error(`crates.io did not return max_version for ${crate}`); + } + + return latestVersion; +} + +function replaceDependencyVersion(content, dependency, latestVersion) { + const pattern = new RegExp( + `(^\\s*${escapeRegExp(dependency)}\\s*=\\s*\\{[^\\n]*\\bversion\\s*=\\s*")([^"]+)(")`, + 'm' + ); + + const match = content.match(pattern); + + if (!match) { + throw new Error(`Could not find a version field for ${dependency} in rust/Cargo.toml`); + } + + const currentVersion = match[2]; + + if (currentVersion === latestVersion) { + return { changed: false, content, currentVersion }; + } + + return { + changed: true, + currentVersion, + content: content.replace(pattern, `$1${latestVersion}$3`), + }; +} + +function updateLockfile(dependency, version) { + execFileSync( + 'cargo', + ['update', '--manifest-path', 'rust/Cargo.toml', '-p', dependency, '--precise', version], + { stdio: 'inherit' } + ); +} + +async function main() { + let manifest = await readFile(manifestPath, 'utf8'); + const plannedUpdates = []; + + for (const entry of upstreamCrates) { + const latestVersion = await fetchLatestVersion(entry.crate); + const result = replaceDependencyVersion(manifest, entry.dependency, latestVersion); + + if (!result.changed) { + console.log(`${entry.dependency} is already at ${latestVersion}`); + continue; + } + + manifest = result.content; + plannedUpdates.push({ + dependency: entry.dependency, + currentVersion: result.currentVersion, + latestVersion, + }); + } + + if (plannedUpdates.length === 0) { + console.log('No upstream wreq crate updates found.'); + + return; + } + + await writeFile(manifestPath, manifest); + + for (const update of plannedUpdates) { + console.log(`Updating ${update.dependency}: ${update.currentVersion} -> ${update.latestVersion}`); + updateLockfile(update.dependency, update.latestVersion); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +});