diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 80ea62c..df8a371 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -4,10 +4,5 @@ set -eu repo_root="$(git rev-parse --show-toplevel)" cd "$repo_root" -echo "pre-commit: running lint autofix" -npm run lint:fix - -echo "pre-commit: running formatter" -npm run format - -git add -A -- src +echo "pre-commit: formatting and linting staged files" +npm run lint:staged diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3f91c76..f9d9c59 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,10 +53,10 @@ jobs: runs-on: ${{ matrix.settings.host }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 20 @@ -76,7 +76,7 @@ jobs: run: ${{ matrix.settings.build }} - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: bindings-${{ matrix.settings.target }} path: rust/*.node @@ -102,10 +102,10 @@ jobs: - target: x86_64-pc-windows-msvc steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 24 registry-url: 'https://registry.npmjs.org' @@ -138,7 +138,7 @@ jobs: run: npm ci - name: Download artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: bindings-${{ matrix.settings.target }} path: artifacts @@ -167,10 +167,10 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 24 registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml index bac41b6..60db98e 100644 --- a/.github/workflows/dependency-audit.yml +++ b/.github/workflows/dependency-audit.yml @@ -14,10 +14,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: package.json cache: npm diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7bcc36f..293f399 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,10 +33,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} cache: 'npm' @@ -45,7 +45,7 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Cache Rust dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cargo/bin/ @@ -84,10 +84,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 20 cache: 'npm' diff --git a/.github/workflows/wreq-upstream.yml b/.github/workflows/wreq-upstream.yml index 6da3591..836b97b 100644 --- a/.github/workflows/wreq-upstream.yml +++ b/.github/workflows/wreq-upstream.yml @@ -19,12 +19,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: package.json cache: npm @@ -54,7 +54,7 @@ jobs: - name: Create pull request if: steps.changes.outputs.changed == 'true' - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@v8 with: branch: chore/update-wreq-upstream delete-branch: true diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 26d3627..6da4951 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -2,12 +2,17 @@ "$schema": "./node_modules/oxfmt/configuration_schema.json", "sortPackageJson": false, "sortImports": { + "groups": [["type"], ["external"], ["builtin"], ["parent", "sibling"], "index"], "sortSideEffects": true, "newlinesBetween": false }, "ignorePatterns": [ "**/dist/**", "**/node_modules/**", + "**/.release/**", + "**/.release-stubs/**", + "**/.tmp-release/**", + "**/artifacts/**" ], "semi": true, "trailingComma": "es5", @@ -16,4 +21,4 @@ "tabWidth": 2, "printWidth": 100, "endOfLine": "lf" -} \ No newline at end of file +} diff --git a/.oxlintrc.json b/.oxlintrc.json index 68d641b..58f103b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,41 +1,164 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "import", "unicorn"], "jsPlugins": [ { "name": "stylistic", "specifier": "@stylistic/eslint-plugin" } ], + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "env": { + "browser": true, + "node": true, + "es2022": true + }, + "ignorePatterns": [ + "**/dist/**", + "**/node_modules/**", + "**/.release/**", + "**/.release-stubs/**", + "**/.tmp-release/**", + "**/artifacts/**", + "**/src/generated/**", + "**/src/config/generated/**" + ], "rules": { + "curly": ["error", "all"], + "eqeqeq": ["error", "always", { "null": "ignore" }], + "import-x/no-duplicates": "warn", + "import/no-unassigned-import": "off", + "no-debugger": "error", + "no-eval": "error", + "no-implied-eval": "error", + "no-new-func": "error", + "no-underscore-dangle": "off", + "no-var": "error", + "prefer-const": "error", + "radix": "error", "stylistic/padding-line-between-statements": [ "error", { "blankLine": "always", - "prev": [ - "const", - "let", - "var" - ], + "prev": ["const", "let", "var"], "next": "*" }, { "blankLine": "always", "prev": "*", - "next": "return" + "next": ["const", "let", "var"] }, { "blankLine": "any", - "prev": [ - "const", - "let", - "var" - ], - "next": [ - "const", - "let", - "var" - ] + "prev": ["const", "let", "var"], + "next": ["const", "let", "var"] + }, + { + "blankLine": "always", + "prev": "*", + "next": "block-like" + }, + { + "blankLine": "always", + "prev": "block-like", + "next": "*" + }, + { + "blankLine": "always", + "prev": "*", + "next": { + "selector": "ExpressionStatement[expression.type='AwaitExpression']" + } + }, + { + "blankLine": "never", + "prev": { + "selector": "ExpressionStatement[expression.type='AwaitExpression']" + }, + "next": { + "selector": "ExpressionStatement[expression.type='AwaitExpression']" + } + }, + { + "blankLine": "always", + "prev": { + "selector": "ExpressionStatement[expression.type='AwaitExpression']" + }, + "next": { + "selector": "ExpressionStatement[expression.type='AwaitExpression']", + "lineMode": "multiline" + } + }, + { + "blankLine": "always", + "prev": { + "selector": "ExpressionStatement[expression.type='AwaitExpression']", + "lineMode": "multiline" + }, + "next": { + "selector": "ExpressionStatement[expression.type='AwaitExpression']" + } + }, + { + "blankLine": "always", + "prev": { + "selector": ":not(ImportDeclaration)", + "lineMode": "multiline" + }, + "next": "*" + }, + { + "blankLine": "always", + "prev": { + "selector": "ImportDeclaration" + }, + "next": { + "selector": ":not(ImportDeclaration)" + } + }, + { + "blankLine": "always", + "prev": "*", + "next": "return" } - ] - } + ], + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-require-imports": "error", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + "unicorn/filename-case": [ + "error", + { + "cases": { + "camelCase": true, + "kebabCase": true, + "pascalCase": true + } + } + ], + "unicorn/prefer-node-protocol": "error" + }, + "overrides": [ + { + "files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + "rules": { + "no-undef": "off" + } + }, + { + "files": ["src/native/binding.ts", "scripts/generate-browser-profiles.mjs"], + "rules": { + "@typescript-eslint/no-require-imports": "off" + } + } + ] } diff --git a/README.md b/README.md index fbc17df..e347535 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,12 @@ console.log(await response.json()); import { fetch } from 'node-wreq'; const response = await fetch('https://httpbin.org/get', { - browser: 'firefox_139', + browser: { + profile: 'firefox_151', + platform: 'linux', + http2: true, + headers: true, + }, query: { source: 'node-wreq', debug: true, @@ -142,7 +147,10 @@ console.log(await response.json()); ### upload `FormData` -`FormData` request bodies work like `fetch`: the multipart boundary and `content-type` header are generated automatically. +`FormData` request bodies work like `fetch`. By default, `node-wreq` generates a +`----WebKitFormBoundary...` boundary so the multipart envelope matches browser traffic as well as +the TLS and HTTP fingerprints do. File parts are encoded by native `wreq` multipart support and +streamed with backpressure instead of buffering the complete form in memory. ```ts const formData = new FormData(); @@ -153,6 +161,7 @@ formData.append('upload', new File(['hello'], 'hello.txt', { type: 'text/plain' const response = await fetch('https://api.example.com/upload', { method: 'POST', body: formData, + // Optional: multipartBoundary: '----my-explicit-boundary', }); console.log(await response.json()); @@ -206,7 +215,8 @@ readable.pipe(process.stdout); ## 🧩 client   ·   [↑](#contents) -Use `createClient(...)` when requests share defaults: +Use `createClient(...)` when requests share defaults. A client also owns reusable native connection +and TLS-session pools; it is not just a JavaScript defaults wrapper. - `baseURL` - browser profile @@ -250,6 +260,9 @@ const created = await client.post( ); console.log(created.status); + +// Optional for long-lived processes; pooled resources are also released after GC. +client.close(); ``` ### extend a client @@ -291,6 +304,37 @@ Typical profiles include browser families like: - Opera - OkHttp +The current upstream snapshot includes the newest profiles through `chrome_149`, `edge_148`, +`firefox_151`, `opera_131`, and `safari_26_4`. When `browser` is omitted, `chrome_149` is used. + +You can also select a platform explicitly or ask upstream to choose a profile automatically: + +```ts +await fetch('https://example.com', { + browser: { + profile: 'chrome_149', + platform: 'windows', + // Optional component switches from upstream's Emulation builder: + http2: true, + headers: true, + }, +}); + +await fetch('https://example.com', { + browser: { mode: 'random' }, +}); + +await fetch('https://example.com', { + // Uses current browser-market-share weights and valid browser/platform pairings. + browser: { mode: 'weighted-random' }, +}); +``` + +Set `browser.http2` to `false` when you want the selected TLS/profile identity without its +HTTP/2 fingerprint settings. Set `browser.headers` to `false` to omit the profile's default +headers and header ordering; the top-level `disableDefaultHeaders` option remains a convenient +request-wide equivalent for the latter. + ## 🪝 hooks   ·   [↑](#contents) Hooks are the request pipeline. @@ -701,6 +745,7 @@ Notes: - cookies from `cookieJar` are sent during handshake - duplicate subprotocols are rejected +- `httpVersion: '1.1' | '2'` explicitly selects the handshake HTTP version ## 🧪 networking / transport knobs   ·   [↑](#contents) @@ -796,6 +841,7 @@ await fetch('https://example.com', { browser: 'chrome_137', tlsOptions: { greaseEnabled: true, + keyShares: ['X25519_MLKEM768', 'X25519', 'P256'], }, http1Options: { writev: true, @@ -813,6 +859,43 @@ Use these only when: - you are comparing transport behavior - you want to debug fingerprint mismatches +Custom TLS/HTTP1/HTTP2 options are overlaid on the selected browser profile. Unspecified profile +settings remain intact. + +### native connection and TLS-session pools + +`createClient()` reuses the underlying native `wreq::Client`, including HTTP keep-alive connections +and TLS sessions. Builder-affecting per-request overrides automatically get an isolated client +variant so a proxy, DNS, TLS, or browser change cannot reuse an incompatible pool. + +```ts +const client = createClient({ + baseURL: 'https://api.example.com', + poolIdleTimeout: 90_000, + poolMaxIdlePerHost: 8, + poolMaxSize: 128, + tlsSessionCacheCapacity: 8, +}); + +await client.get('/account', { + // Requests in different groups never share pooled connections. + connectionGroup: 'account-session', +}); + +const response = await client.get('/health', { + // Static form: discard this request's connection after the response. + forbidConnectionReuse: true, +}); + +// Conditional form: call before consuming the body. +response.wreq.forbidConnectionReuse(); + +client.close(); +``` + +Set `poolIdleTimeout: false` to disable idle expiry. `connectionGroup` accepts a string or a +non-negative integer. + ### mTLS and custom CAs Use `tlsIdentity` for client certificate authentication and `ca` for a custom trust store: diff --git a/package-lock.json b/package-lock.json index 3fe6b96..6e2b126 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@stylistic/eslint-plugin": "^5.10.0", "@types/node": "^20.19.39", "@types/ws": "^8.18.1", + "lint-staged": "^15.5.2", "oxfmt": "^0.60.0", "oxlint": "^1.60.0", "rimraf": "^6.1.3", @@ -343,9 +344,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -363,9 +361,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -383,9 +378,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -403,9 +395,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -423,9 +412,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -443,9 +429,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -463,9 +446,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -483,9 +463,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -690,9 +667,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -710,9 +684,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -730,9 +701,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -750,9 +718,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -770,9 +735,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -790,9 +752,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -810,9 +769,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -830,9 +786,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1030,6 +983,48 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1041,16 +1036,92 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" } }, "node_modules/cross-spawn": { @@ -1059,7 +1130,6 @@ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1075,7 +1145,6 @@ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -1096,6 +1165,26 @@ "license": "MIT", "peer": true }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1300,6 +1389,37 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1338,6 +1458,19 @@ "node": ">=16.0.0" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1379,6 +1512,32 @@ "license": "ISC", "peer": true }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -1411,6 +1570,16 @@ "node": ">=10.13.0" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1444,6 +1613,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1458,13 +1640,35 @@ "node": ">=0.10.0" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/json-buffer": { "version": "3.0.1", @@ -1516,6 +1720,65 @@ "node": ">= 0.8.0" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lint-staged": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz", + "integrity": "sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^13.1.0", + "debug": "^4.4.0", + "execa": "^8.0.1", + "lilconfig": "^3.1.3", + "listr2": "^8.2.5", + "micromatch": "^4.0.8", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.7.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -1533,6 +1796,59 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/lru-cache": { "version": "11.3.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", @@ -1543,6 +1859,66 @@ "node": "20 || >=22" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -1574,8 +1950,7 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -1585,6 +1960,51 @@ "license": "MIT", "peer": true }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -1763,7 +2183,6 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -1798,6 +2217,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.1.tgz", + "integrity": "sha512-e0F9AOF1JMrCfBsyJOwU9lNvQ0WtXTq0j/4jk0BQ5JSI9VAybPXmDpPRw/2FQ3e5d3ZFN1mLh7jW99m/jjaptw==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -1820,6 +2252,46 @@ "node": ">=6" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rimraf": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", @@ -1846,7 +2318,6 @@ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -1860,11 +2331,97 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinypool": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", @@ -1875,6 +2432,19 @@ "node": "^20.0.0 || >=22.0.0" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -1923,7 +2493,6 @@ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -1945,6 +2514,24 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/ws": { "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", @@ -1967,6 +2554,22 @@ } } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index d2f8f71..f1aa757 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "test": "npm run build && node --test dist/test/node-wreq.spec.js", "lint": "oxlint --deny-warnings src scripts", "lint:fix": "oxlint --fix src scripts", + "lint:staged": "lint-staged", "lint:rust": "cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings", "format": "oxfmt --write src scripts", "format:check": "oxfmt --check src scripts", @@ -73,6 +74,7 @@ "@stylistic/eslint-plugin": "^5.10.0", "@types/node": "^20.19.39", "@types/ws": "^8.18.1", + "lint-staged": "^15.5.2", "oxfmt": "^0.60.0", "oxlint": "^1.60.0", "rimraf": "^6.1.3", @@ -108,5 +110,12 @@ "x86_64-pc-windows-msvc" ] } + }, + "lint-staged": { + "*.{ts,tsx,mts,cts,js,mjs,cjs}": [ + "oxlint --fix --deny-warnings --no-error-on-unmatched-pattern", + "oxfmt --write" + ], + "rust/**/*.rs": "rustfmt --edition 2021" } } diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 6df1744..3857048 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -8,18 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -44,6 +32,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anyhow" version = "1.0.100" @@ -80,6 +74,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bindgen" version = "0.72.1" @@ -100,9 +100,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.9.4" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -113,31 +113,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "boring-sys2" -version = "5.0.0-alpha.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "455d79965f5155dcc88a7abce112c3590883889131b799beda10bf9a813ed669" -dependencies = [ - "bindgen", - "cmake", - "fs_extra", - "fslock", -] - -[[package]] -name = "boring2" -version = "5.0.0-alpha.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183ccc3854411c035410dcdbffafca62084f3a6c33f013c77e83c025d2a08a28" -dependencies = [ - "bitflags", - "boring-sys2", - "foreign-types", - "libc", - "openssl-macros", -] - [[package]] name = "brotli" version = "8.0.2" @@ -159,6 +134,31 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "btls" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c5e60b8c8d282c86360cab651ded04ab0335a7b5390c8d34145cbeab8cacf5f" +dependencies = [ + "bitflags", + "btls-sys", + "foreign-types", + "libc", + "openssl-macros", +] + +[[package]] +name = "btls-sys" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b1b8638a2e1c38a5ae4efa90ae57e643baec35a30d03fc5b399b893adc4954b" +dependencies = [ + "bindgen", + "cmake", + "fs_extra", + "fslock", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -167,9 +167,9 @@ checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytes" -version = "1.10.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" @@ -198,6 +198,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -218,6 +229,16 @@ dependencies = [ "cc", ] +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compression-codecs" version = "0.4.31" @@ -273,6 +294,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -399,9 +429,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.4" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -419,6 +449,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -473,18 +509,18 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-io" @@ -492,29 +528,40 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", + "futures-macro", "futures-sink", "futures-task", "pin-project-lite", - "pin-utils", "slab", ] @@ -560,6 +607,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -589,19 +637,13 @@ dependencies = [ "tracing", ] -[[package]] -name = "hashbrown" -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", + "foldhash 0.1.5", ] [[package]] @@ -610,12 +652,47 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto 0.26.1", + "idna", + "ipnet", + "jni", + "rand 0.10.2", + "thiserror 2.0.17", + "tinyvec", + "tokio", + "tracing", + "url", +] + [[package]] name = "hickory-proto" version = "0.25.2" @@ -635,7 +712,7 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand", + "rand 0.9.2", "ring", "rustls", "thiserror 2.0.17", @@ -647,6 +724,26 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.2", + "ring", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "hickory-resolver" version = "0.25.2" @@ -655,12 +752,12 @@ checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" dependencies = [ "cfg-if", "futures-util", - "hickory-proto", + "hickory-proto 0.25.2", "ipconfig", "moka", "once_cell", "parking_lot", - "rand", + "rand 0.9.2", "resolv-conf", "rustls", "smallvec", @@ -671,14 +768,39 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto 0.26.1", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.2", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror 2.0.17", + "tokio", + "tracing", +] + [[package]] name = "http" -version = "1.3.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -707,9 +829,9 @@ dependencies = [ [[package]] name = "http2" -version = "0.5.16" +version = "0.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7349aa548b6a413a7e7146d5d7e3db4565b3d0fbc8ff53c72e46b44b083cf9f" +checksum = "92d3114be2f413b2e491e686b93a28cda30c355cffc8d091a57f8be4b1342896" dependencies = [ "atomic-waker", "bytes", @@ -718,7 +840,6 @@ dependencies = [ "futures-sink", "http", "indexmap", - "parking_lot", "slab", "smallvec", "tokio", @@ -867,9 +988,12 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] [[package]] name = "itertools" @@ -886,6 +1010,55 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.17", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -914,9 +1087,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -969,6 +1142,15 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "memchr" version = "2.7.6" @@ -1035,6 +1217,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "neon" version = "1.1.1" @@ -1067,13 +1255,14 @@ name = "node-wreq" version = "0.1.0" dependencies = [ "anyhow", - "hickory-resolver", + "hickory-resolver 0.25.2", + "http-body-util", "neon", "serde", "serde_json", - "strum", "thiserror 1.0.69", "tokio", + "tokio-stream", "url", "webpki-root-certs", "wreq", @@ -1096,6 +1285,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1148,15 +1346,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" @@ -1194,6 +1386,17 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1241,7 +1444,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -1251,7 +1465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", ] [[package]] @@ -1263,6 +1477,12 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1327,6 +1547,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1375,14 +1604,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] -name = "schnellru" -version = "0.2.4" +name = "same-file" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "356285bbf17bea63d9e52e96bd18f039672ac92b55b8cb997d6162a2a37d1649" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ - "ahash", - "cfg-if", - "hashbrown 0.13.2", + "winapi-util", ] [[package]] @@ -1453,7 +1680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1478,6 +1705,22 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.11" @@ -1506,24 +1749,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "subtle" version = "2.6.1" @@ -1686,9 +1911,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.1" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1702,12 +1927,12 @@ dependencies = [ ] [[package]] -name = "tokio-boring2" -version = "5.0.0-alpha.13" +name = "tokio-btls" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f81df1210d791f31d72d840de8fbd80b9c3cb324956523048b1413e2bd55756" +checksum = "2e1fd638ec35427faf3b8f412e0fdd6fae76591d79dba40f38fa667d22bc44dd" dependencies = [ - "boring2", + "btls", "tokio", ] @@ -1744,11 +1969,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", @@ -1758,13 +1994,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.16" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -1843,19 +2080,18 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", "http", "httparse", "log", - "rand", + "rand 0.9.2", "sha1", "thiserror 2.0.17", - "utf-8", ] [[package]] @@ -1920,12 +2156,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -1949,6 +2179,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2072,9 +2312,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.3" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05d651ec480de84b762e7be71e6efa7461699c19d9e2c272c8d93455f567786e" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -2119,6 +2359,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2338,20 +2587,17 @@ dependencies = [ [[package]] name = "wreq" -version = "6.0.0-rc.28" +version = "6.0.0-rc.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f79937f6c4df65b3f6f78715b9de2977afe9ee3b3436483c7949a24511e25935" +checksum = "3f0eba5f5814a94e5f1a99156f187133464e525b66bdbc69a9627d46530af2e1" dependencies = [ - "ahash", - "boring2", - "brotli", + "btls", + "btls-sys", "bytes", "cookie", "encoding_rs", - "flate2", - "futures-channel", "futures-util", - "hickory-resolver", + "hickory-resolver 0.26.1", "http", "http-body", "http-body-util", @@ -2359,41 +2605,73 @@ dependencies = [ "httparse", "ipnet", "libc", + "lru", "mime", "mime_guess", "percent-encoding", "pin-project-lite", - "schnellru", "serde", "serde_json", - "smallvec", "socket2", "sync_wrapper", "system-configuration", "tokio", - "tokio-boring2", + "tokio-btls", "tokio-socks", "tokio-tungstenite", + "tokio-util", "tower", "tower-http", "url", - "want", "webpki-root-certs", "windows-registry", - "zstd", + "wreq-proto", + "wreq-rt", +] + +[[package]] +name = "wreq-proto" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a43942f024bb303f1042c9aa3c87fa1d9149f507c65db6e5220a11ccdb207387" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "http2", + "httparse", + "pin-project-lite", + "smallvec", + "tokio", + "tokio-util", + "want", +] + +[[package]] +name = "wreq-rt" +version = "0.2.2-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99e9bce67a3fa3dd3f1503f066d86661c9caf399a763d3bd184da7afaf886c8b" +dependencies = [ + "pin-project-lite", + "tokio", + "wreq-proto", ] [[package]] name = "wreq-util" -version = "3.0.0-rc.11" +version = "3.0.0-rc.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b51558351cf26c9a5daeabf5df5bfab13f1666fa783ad7ddb9a573e04d7fae0a" +checksum = "1d8d73c75be86fbde675988dbe5cb15f02567025c13f6ffab910361ad1ba6c89" dependencies = [ + "brotli", + "flate2", "serde", - "strum", - "strum_macros", "typed-builder", "wreq", + "zstd", ] [[package]] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 8e36f5a..beb73c3 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -9,9 +9,9 @@ crate-type = ["cdylib"] [dependencies] # HTTP client with browser impersonation -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.11", features = ["emulation-rand", "emulation-serde"] } -strum = "0.27.2" +wreq = { version = "6.0.0-rc.29", default-features = false, features = ["tokio-rt", "gzip", "brotli", "deflate", "zstd", "socks", "cookies", "json", "charset", "multipart", "stream", "hickory-dns", "system-proxy", "webpki-roots", "ws"] } +wreq-util = { version = "3.0.0-rc.14", features = ["emulation-serde"] } +http-body-util = "0.1" # Neon for Node.js bindings neon = { version = "1.0", default-features = false, features = ["napi-6"] } @@ -26,6 +26,7 @@ thiserror = "1.0" # Async runtime (if needed) tokio = { version = "1.0", features = ["full"] } +tokio-stream = "0.1" webpki-root-certs = "1.0.3" hickory-resolver = { version = "0.25.2", features = ["system-config", "https-ring", "webpki-roots"] } url = "2.5" diff --git a/rust/src/emulation/builders.rs b/rust/src/emulation/builders.rs index 7f1af5f..5d3a29c 100644 --- a/rust/src/emulation/builders.rs +++ b/rust/src/emulation/builders.rs @@ -1,246 +1,287 @@ use crate::emulation::parse::{ parse_alpn_protocol, parse_alps_protocol, parse_certificate_compression_algorithm, - parse_http2_setting_id, parse_pseudo_id, parse_tls_version, + parse_http2_setting_id, parse_key_share, parse_pseudo_id, parse_tls_version, }; use crate::emulation::payload::{ - CustomHttp1Options, CustomHttp2ExperimentalSetting, CustomHttp2Options, CustomHttp2Priority, - CustomTlsOptions, + CustomHttp1Options, CustomHttp2Options, CustomHttp2Priority, CustomTlsOptions, }; -use anyhow::{anyhow, bail, Result}; +use anyhow::{bail, Result}; use std::collections::HashSet; use std::time::Duration; use wreq::{ http1::Http1Options, http2::{ - ExperimentalSettings, Http2Options, Priorities, Priority, PseudoOrder, Setting, SettingId, - SettingsOrder, StreamDependency, StreamId, + Http2Options, Priorities, Priority, PseudoOrder, SettingsOrder, StreamDependency, StreamId, }, - tls::{ExtensionType, TlsOptions}, + tls::{ExtensionType, KeyShare, TlsOptions}, }; -pub fn build_tls_options(options: CustomTlsOptions) -> Result { - let mut builder = TlsOptions::builder(); +const DEFAULT_KEY_SHARES: &[KeyShare] = + &[KeyShare::X25519_MLKEM768, KeyShare::X25519, KeyShare::P256]; + +pub fn apply_tls_options(mut base: TlsOptions, options: CustomTlsOptions) -> Result { + if options.key_shares.is_some() && options.key_shares_limit.is_some() { + bail!("Invalid emulation tlsOptions: keyShares and keySharesLimit cannot both be set"); + } if let Some(alpn_protocols) = options.alpn_protocols { - builder = builder.alpn_protocols( + base.alpn_protocols = Some( alpn_protocols .into_iter() .map(|protocol| parse_alpn_protocol(&protocol)) - .collect::>>()?, + .collect::>>()? + .into(), ); } if let Some(alps_protocols) = options.alps_protocols { - builder = builder.alps_protocols( + base.alps_protocols = Some( alps_protocols .into_iter() .map(|protocol| parse_alps_protocol(&protocol)) - .collect::>>()?, + .collect::>>()? + .into(), ); } if let Some(value) = options.alps_use_new_codepoint { - builder = builder.alps_use_new_codepoint(value); + base.alps_use_new_codepoint = value; } if let Some(value) = options.session_ticket { - builder = builder.session_ticket(value); + base.session_ticket = value; } if let Some(value) = options.min_tls_version { - builder = builder.min_tls_version(Some(parse_tls_version(&value)?)); + base.min_tls_version = Some(parse_tls_version(&value)?); } if let Some(value) = options.max_tls_version { - builder = builder.max_tls_version(Some(parse_tls_version(&value)?)); + base.max_tls_version = Some(parse_tls_version(&value)?); } if let Some(value) = options.pre_shared_key { - builder = builder.pre_shared_key(value); + base.pre_shared_key = value; } if let Some(value) = options.enable_ech_grease { - builder = builder.enable_ech_grease(value); + base.enable_ech_grease = value; } if let Some(value) = options.permute_extensions { - builder = builder.permute_extensions(Some(value)); + base.permute_extensions = Some(value); } if let Some(value) = options.grease_enabled { - builder = builder.grease_enabled(Some(value)); + base.grease_enabled = Some(value); } if let Some(value) = options.enable_ocsp_stapling { - builder = builder.enable_ocsp_stapling(value); + base.enable_ocsp_stapling = value; } if let Some(value) = options.enable_signed_cert_timestamps { - builder = builder.enable_signed_cert_timestamps(value); + base.enable_signed_cert_timestamps = value; } if let Some(value) = options.record_size_limit { - builder = builder.record_size_limit(Some(value)); + base.record_size_limit = Some(value); } if let Some(value) = options.psk_skip_session_ticket { - builder = builder.psk_skip_session_ticket(value); + base.psk_skip_session_ticket = value; } if let Some(value) = options.key_shares_limit { - builder = builder.key_shares_limit(Some(value)); + if value == 0 { + bail!("Invalid emulation tlsOptions.keySharesLimit: must be greater than 0"); + } + + let mut key_shares = base + .key_shares + .take() + .map(|shares| shares.into_owned()) + .unwrap_or_else(|| DEFAULT_KEY_SHARES.to_vec()); + key_shares.truncate(usize::from(value)); + base.key_shares = Some(key_shares.into()); + } + if let Some(key_shares) = options.key_shares { + let key_shares = key_shares + .into_iter() + .map(|key_share| parse_key_share(&key_share)) + .collect::>>()?; + + if key_shares.is_empty() { + bail!("Invalid emulation tlsOptions.keyShares: must not be empty"); + } + + base.key_shares = Some(key_shares.into()); } if let Some(value) = options.psk_dhe_ke { - builder = builder.psk_dhe_ke(value); + base.psk_dhe_ke = value; } if let Some(value) = options.renegotiation { - builder = builder.renegotiation(value); + base.renegotiation = value; } if let Some(value) = options.delegated_credentials { - builder = builder.delegated_credentials(value); + base.delegated_credentials = Some(value.into()); } if let Some(value) = options.curves_list { - builder = builder.curves_list(value); + base.curves_list = Some(value.into()); } if let Some(value) = options.cipher_list { - builder = builder.cipher_list(value); + base.cipher_list = Some(value.into()); } if let Some(value) = options.sigalgs_list { - builder = builder.sigalgs_list(value); + base.sigalgs_list = Some(value.into()); } if let Some(value) = options.certificate_compression_algorithms { - builder = builder.certificate_compression_algorithms( + base.certificate_compressors = Some( value .into_iter() .map(|algorithm| parse_certificate_compression_algorithm(&algorithm)) - .collect::>>()?, + .collect::>>()? + .into(), ); } if let Some(value) = options.extension_permutation { - builder = builder.extension_permutation( + base.extension_permutation = Some( value .into_iter() .map(ExtensionType::from) - .collect::>(), + .collect::>() + .into(), ); } if let Some(value) = options.aes_hw_override { - builder = builder.aes_hw_override(Some(value)); + base.aes_hw_override = Some(value); } if let Some(value) = options.preserve_tls13_cipher_list { - builder = builder.preserve_tls13_cipher_list(Some(value)); + base.preserve_tls13_cipher_list = Some(value); } if let Some(value) = options.random_aes_hw_override { - builder = builder.random_aes_hw_override(value); + base.random_aes_hw_override = value; } - Ok(builder.build()) + Ok(base) } -pub fn build_http1_options(options: CustomHttp1Options) -> Result { - let mut builder = Http1Options::builder(); - +pub fn apply_http1_options( + mut base: Http1Options, + options: CustomHttp1Options, +) -> Result { if let Some(value) = options.http09_responses { - builder = builder.http09_responses(value); + base.h09_responses = value; } if let Some(value) = options.writev { - builder = builder.writev(Some(value)); + base.h1_writev = Some(value); } if let Some(value) = options.max_headers { - builder = builder.max_headers(value); + base.h1_max_headers = Some(value); } if let Some(value) = options.read_buf_exact_size { - builder = builder.read_buf_exact_size(Some(value)); + base.h1_read_buf_exact_size = Some(value); + base.h1_max_buf_size = None; } if let Some(value) = options.max_buf_size { if value < 8192 { bail!("Invalid emulation http1Options.maxBufSize: must be at least 8192"); } - builder = builder.max_buf_size(value); + base.h1_max_buf_size = Some(value); + base.h1_read_buf_exact_size = None; } if options.read_buf_exact_size.is_some() && options.max_buf_size.is_some() { bail!("Invalid emulation http1Options: readBufExactSize and maxBufSize cannot both be set"); } if let Some(value) = options.ignore_invalid_headers_in_responses { - builder = builder.ignore_invalid_headers_in_responses(value); + base.ignore_invalid_headers_in_responses = value; } if let Some(value) = options.allow_spaces_after_header_name_in_responses { - builder = builder.allow_spaces_after_header_name_in_responses(value); + base.allow_spaces_after_header_name_in_responses = value; } if let Some(value) = options.allow_obsolete_multiline_headers_in_responses { - builder = builder.allow_obsolete_multiline_headers_in_responses(value); + base.allow_obsolete_multiline_headers_in_responses = value; } - Ok(builder.build()) + Ok(base) } -pub fn build_http2_options(options: CustomHttp2Options) -> Result { - let mut builder = Http2Options::builder(); - +pub fn apply_http2_options( + mut base: Http2Options, + options: CustomHttp2Options, +) -> Result { if let Some(value) = options.adaptive_window { - builder = builder.adaptive_window(value); + base.adaptive_window = value; } if let Some(value) = options.initial_stream_id { - builder = builder.initial_stream_id(Some(value)); + base.initial_stream_id = Some(value); } if let Some(value) = options.initial_connection_window_size { - builder = builder.initial_connection_window_size(Some(value)); + base.initial_conn_window_size = value; + base.adaptive_window = false; } if let Some(value) = options.initial_window_size { - builder = builder.initial_window_size(Some(value)); + base.initial_window_size = value; + base.adaptive_window = false; } if let Some(value) = options.initial_max_send_streams { - builder = builder.initial_max_send_streams(Some(value)); + base.initial_max_send_streams = value; } if let Some(value) = options.max_frame_size { - builder = builder.max_frame_size(Some(value)); + base.max_frame_size = Some(value); } if let Some(value) = options.keep_alive_interval { - builder = builder.keep_alive_interval(Some(Duration::from_millis(value))); + base.keep_alive_interval = Some(Duration::from_millis(value)); } if let Some(value) = options.keep_alive_timeout { - builder = builder.keep_alive_timeout(Duration::from_millis(value)); + base.keep_alive_timeout = Duration::from_millis(value); } if let Some(value) = options.keep_alive_while_idle { - builder = builder.keep_alive_while_idle(value); + base.keep_alive_while_idle = value; } if let Some(value) = options.max_concurrent_reset_streams { - builder = builder.max_concurrent_reset_streams(value); + base.max_concurrent_reset_streams = Some(value); } if let Some(value) = options.max_send_buffer_size { - builder = builder.max_send_buf_size(value); + if value > u32::MAX as usize { + bail!("Invalid emulation http2Options.maxSendBufferSize: exceeds u32::MAX"); + } + base.max_send_buffer_size = value; } if let Some(value) = options.max_concurrent_streams { - builder = builder.max_concurrent_streams(Some(value)); + base.max_concurrent_streams = Some(value); } if let Some(value) = options.max_header_list_size { - builder = builder.max_header_list_size(value); + base.max_header_list_size = Some(value); } if let Some(value) = options.max_pending_accept_reset_streams { - builder = builder.max_pending_accept_reset_streams(Some(value)); + base.max_pending_accept_reset_streams = Some(value); } if let Some(value) = options.enable_push { - builder = builder.enable_push(value); + base.enable_push = Some(value); } if let Some(value) = options.header_table_size { - builder = builder.header_table_size(Some(value)); + base.header_table_size = Some(value); } if let Some(value) = options.enable_connect_protocol { - builder = builder.enable_connect_protocol(value); + base.enable_connect_protocol = Some(value); } if let Some(value) = options.no_rfc7540_priorities { - builder = builder.no_rfc7540_priorities(value); + base.no_rfc7540_priorities = Some(value); } if let Some(settings_order) = options.settings_order { - builder = builder.settings_order(Some(build_settings_order(settings_order)?)); + base.settings_order = Some(build_settings_order(settings_order)?); } if let Some(pseudo_order) = options.headers_pseudo_order { - builder = builder.headers_pseudo_order(Some(build_pseudo_order(pseudo_order)?)); + base.headers_pseudo_order = Some(build_pseudo_order(pseudo_order)?); } if let Some(dep) = options.headers_stream_dependency { - builder = builder.headers_stream_dependency(Some(StreamDependency::new( + base.headers_stream_dependency = Some(StreamDependency::new( StreamId::from(dep.dependency_id), dep.weight, dep.exclusive, - ))); + )); } if let Some(priorities) = options.priorities { - builder = builder.priorities(Some(build_priorities(priorities)?)); + base.priorities = Some(build_priorities(priorities)?); } if let Some(experimental_settings) = options.experimental_settings { - builder = builder - .experimental_settings(Some(build_experimental_settings(experimental_settings)?)); + if !experimental_settings.is_empty() { + bail!( + "Unsupported emulation http2Options.experimentalSettings: wreq 6.0.0-rc.29 no longer exposes custom HTTP/2 settings" + ); + } } - Ok(builder.build()) + Ok(base) } fn build_pseudo_order(pseudo_order: Vec) -> Result { @@ -305,42 +346,42 @@ fn build_priorities(priorities: Vec) -> Result Ok(builder.build()) } -fn build_experimental_settings( - experimental_settings: Vec, -) -> Result { - let mut builder = ExperimentalSettings::builder(); - let mut seen_ids = HashSet::with_capacity(experimental_settings.len()); - let max_id = 15u16; +#[cfg(test)] +mod tests { + use super::*; - for setting in experimental_settings { - if setting.id == 0 || setting.id > max_id { - bail!( - "Invalid emulation http2Options.experimentalSettings entry: id must be between 1 and {}", - max_id - ); - } - if !matches!(SettingId::from(setting.id), SettingId::Unknown(_)) { - bail!( - "Invalid emulation http2Options.experimentalSettings entry: {} is a standard HTTP/2 setting id", - setting.id - ); - } - if !seen_ids.insert(setting.id) { - bail!( - "Duplicate emulation http2Options.experimentalSettings id: {}", - setting.id - ); - } + #[test] + fn tls_overrides_preserve_profile_defaults_and_limit_profile_key_shares() { + let mut base = TlsOptions::default(); + base.session_ticket = false; + base.key_shares = Some(vec![KeyShare::X25519, KeyShare::P256].into()); + let custom = CustomTlsOptions { + grease_enabled: Some(true), + key_shares_limit: Some(1), + ..Default::default() + }; - let setting = - Setting::from_id(SettingId::Unknown(setting.id), setting.value).ok_or_else(|| { - anyhow!( - "Invalid emulation http2Options.experimentalSettings id: {}", - setting.id - ) - })?; - builder = builder.push(setting); + let merged = apply_tls_options(base, custom).expect("TLS options should merge"); + + assert!(!merged.session_ticket); + assert_eq!(merged.grease_enabled, Some(true)); + assert_eq!( + merged.key_shares.as_deref(), + Some([KeyShare::X25519].as_slice()) + ); } - Ok(builder.build()) + #[test] + fn explicit_key_shares_and_limit_are_mutually_exclusive() { + let custom = CustomTlsOptions { + key_shares: Some(vec!["X25519".to_string()]), + key_shares_limit: Some(1), + ..Default::default() + }; + + let error = apply_tls_options(TlsOptions::default(), custom) + .expect_err("conflicting key-share options should fail"); + + assert!(error.to_string().contains("cannot both be set")); + } } diff --git a/rust/src/emulation/parse.rs b/rust/src/emulation/parse.rs index b72fd1d..5d648ea 100644 --- a/rust/src/emulation/parse.rs +++ b/rust/src/emulation/parse.rs @@ -1,8 +1,9 @@ use anyhow::{bail, Result}; use wreq::{ http2::{PseudoId, SettingId}, - tls::{AlpnProtocol, AlpsProtocol, CertificateCompressionAlgorithm, TlsVersion}, + tls::{compress::CertificateCompressor, AlpnProtocol, AlpsProtocol, KeyShare, TlsVersion}, }; +use wreq_util::emulate::compress::{BrotliCompressor, ZlibCompressor, ZstdCompressor}; pub fn parse_tls_version(value: &str) -> Result { match value { @@ -34,15 +35,31 @@ pub fn parse_alps_protocol(value: &str) -> Result { pub fn parse_certificate_compression_algorithm( value: &str, -) -> Result { +) -> Result<&'static dyn CertificateCompressor> { match value { - "zlib" => Ok(CertificateCompressionAlgorithm::ZLIB), - "brotli" => Ok(CertificateCompressionAlgorithm::BROTLI), - "zstd" => Ok(CertificateCompressionAlgorithm::ZSTD), + "zlib" => Ok(&ZlibCompressor), + "brotli" => Ok(&BrotliCompressor), + "zstd" => Ok(&ZstdCompressor), other => bail!("Invalid certificate compression algorithm: {other}"), } } +pub fn parse_key_share(value: &str) -> Result { + match value { + "P256" => Ok(KeyShare::P256), + "P384" => Ok(KeyShare::P384), + "P521" => Ok(KeyShare::P521), + "X25519" => Ok(KeyShare::X25519), + "X25519_MLKEM768" => Ok(KeyShare::X25519_MLKEM768), + "X25519_KYBER768_DRAFT00" => Ok(KeyShare::X25519_KYBER768_DRAFT00), + "P256_KYBER768_DRAFT00" => Ok(KeyShare::P256_KYBER768_DRAFT00), + "MLKEM1024" => Ok(KeyShare::MLKEM1024), + "FFDHE2048" => Ok(KeyShare::FFDHE2048), + "FFDHE3072" => Ok(KeyShare::FFDHE3072), + other => bail!("Invalid TLS key share: {other}"), + } +} + pub fn parse_pseudo_id(value: &str) -> Result { match value { "Method" => Ok(PseudoId::Method), diff --git a/rust/src/emulation/payload.rs b/rust/src/emulation/payload.rs index 1029def..5f48811 100644 --- a/rust/src/emulation/payload.rs +++ b/rust/src/emulation/payload.rs @@ -45,6 +45,8 @@ pub struct CustomTlsOptions { #[serde(default)] pub key_shares_limit: Option, #[serde(default)] + pub key_shares: Option>, + #[serde(default)] pub psk_dhe_ke: Option, #[serde(default)] pub renegotiation: Option, @@ -137,7 +139,7 @@ pub struct CustomHttp2Options { #[serde(default)] pub priorities: Option>, #[serde(default)] - pub experimental_settings: Option>, + pub experimental_settings: Option>, } #[derive(Debug, Deserialize)] @@ -155,9 +157,3 @@ pub struct CustomHttp2StreamDependency { #[serde(default)] pub exclusive: bool, } - -#[derive(Debug, Deserialize)] -pub struct CustomHttp2ExperimentalSetting { - pub id: u16, - pub value: u32, -} diff --git a/rust/src/emulation/resolve.rs b/rust/src/emulation/resolve.rs index 3e57ca7..c5bb38d 100644 --- a/rust/src/emulation/resolve.rs +++ b/rust/src/emulation/resolve.rs @@ -1,14 +1,35 @@ -use crate::emulation::builders::{build_http1_options, build_http2_options, build_tls_options}; +use crate::emulation::builders::{apply_http1_options, apply_http2_options, apply_tls_options}; use crate::emulation::payload::CustomEmulationPayload; use anyhow::{Context, Result}; -use wreq::{Emulation as WreqEmulation, EmulationFactory}; -use wreq_util::Emulation as BrowserEmulation; +use wreq::{Emulation as WreqEmulation, IntoEmulation}; +use wreq_util::{Emulation as BrowserEmulation, Platform, Profile}; pub fn resolve_emulation( - browser: BrowserEmulation, + browser: Profile, + mode: &str, + platform: Option<&str>, + http2: Option, + headers: Option, emulation_json: Option<&str>, ) -> Result { - let mut emulation = browser.emulation(); + let browser_emulation = match mode { + "fixed" => BrowserEmulation::builder() + .profile(browser) + .platform(parse_platform(platform)?) + .build(), + "random" => BrowserEmulation::random(), + "weighted-random" => BrowserEmulation::weighted_random(), + other => anyhow::bail!("Invalid browser emulation mode: {other}"), + }; + let mut emulation = browser_emulation.into_emulation(); + + if http2 == Some(false) { + emulation.http2_options = None; + } + if headers == Some(false) { + emulation.headers.clear(); + emulation.orig_headers = Default::default(); + } if let Some(emulation_json) = emulation_json { let payload = parse_payload(emulation_json)?; @@ -24,16 +45,53 @@ fn parse_payload(emulation_json: &str) -> Result { fn apply_payload(emulation: &mut WreqEmulation, payload: CustomEmulationPayload) -> Result<()> { if let Some(tls_options) = payload.tls_options { - *emulation.tls_options_mut() = Some(build_tls_options(tls_options)?); + let base = emulation.tls_options.take().unwrap_or_default(); + emulation.tls_options = Some(apply_tls_options(base, tls_options)?); } if let Some(http1_options) = payload.http1_options { - *emulation.http1_options_mut() = Some(build_http1_options(http1_options)?); + let base = emulation.http1_options.take().unwrap_or_default(); + emulation.http1_options = Some(apply_http1_options(base, http1_options)?); } if let Some(http2_options) = payload.http2_options { - *emulation.http2_options_mut() = Some(build_http2_options(http2_options)?); + let base = emulation.http2_options.take().unwrap_or_default(); + emulation.http2_options = Some(apply_http2_options(base, http2_options)?); } Ok(()) } + +fn parse_platform(platform: Option<&str>) -> Result { + match platform.unwrap_or("macos") { + "windows" => Ok(Platform::Windows), + "macos" => Ok(Platform::MacOS), + "linux" => Ok(Platform::Linux), + "android" => Ok(Platform::Android), + "ios" => Ok(Platform::IOS), + other => anyhow::bail!("Invalid browser platform: {other}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_disable_profile_http2_and_headers() { + let emulation = resolve_emulation( + Profile::Chrome149, + "fixed", + Some("linux"), + Some(false), + Some(false), + None, + ) + .expect("emulation should resolve"); + + assert!(emulation.http2_options.is_none()); + assert!(emulation.headers.is_empty()); + assert!(emulation.orig_headers.is_empty()); + assert!(emulation.tls_options.is_some()); + } +} diff --git a/rust/src/napi/body.rs b/rust/src/napi/body.rs index ddc5ae7..dea151f 100644 --- a/rust/src/napi/body.rs +++ b/rust/src/napi/body.rs @@ -1,4 +1,4 @@ -use crate::store::body_store::{cancel_body, read_body_chunk}; +use crate::store::body_store::{cancel_body, forbid_body_recycle, read_body_chunk}; use neon::prelude::*; use neon::types::JsBuffer; @@ -37,8 +37,18 @@ fn cancel_body_js(mut cx: FunctionContext) -> JsResult { Ok(cx.boolean(cancel_body(handle))) } +fn forbid_body_recycle_js(mut cx: FunctionContext) -> JsResult { + let handle = cx.argument::(0)?.value(&mut cx) as u64; + + match forbid_body_recycle(handle) { + Ok(value) => Ok(cx.boolean(value)), + Err(error) => cx.throw_error(format!("{:#}", error)), + } +} + pub fn register(cx: &mut ModuleContext) -> NeonResult<()> { cx.export_function("readBodyChunk", read_body_chunk_js)?; cx.export_function("cancelBody", cancel_body_js)?; + cx.export_function("forbidBodyRecycle", forbid_body_recycle_js)?; Ok(()) } diff --git a/rust/src/napi/convert.rs b/rust/src/napi/convert.rs index 279bdeb..b4ad945 100644 --- a/rust/src/napi/convert.rs +++ b/rust/src/napi/convert.rs @@ -1,8 +1,10 @@ use crate::emulation::resolve_emulation; use crate::napi::profiles::parse_browser_emulation; +use crate::store::upload_store::take_upload_receiver; use crate::transport::types::{ - CertificateAuthorityOptions, DnsOptions, LocalBindOptions, RequestOptions, Response, - TlsDangerOptions, TlsDebugOptions, TlsIdentityOptions, TlsKeylogOptions, + CertificateAuthorityOptions, ConnectionGroup, DnsOptions, LocalBindOptions, + MultipartBodyOptions, MultipartPartOptions, PoolIdleTimeout, RequestBody, RequestOptions, + Response, TlsDangerOptions, TlsDebugOptions, TlsIdentityOptions, TlsKeylogOptions, WebSocketConnectOptions, WebSocketConnection, }; use neon::prelude::*; @@ -38,6 +40,107 @@ fn js_value_to_positive_usize( Ok(value.ceil() as usize) } +fn js_value_to_non_negative_usize( + cx: &mut FunctionContext, + value: Handle, + name: &str, +) -> NeonResult { + let value = value.downcast::(cx).or_throw(cx)?.value(cx); + + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 { + return cx.throw_type_error(format!("{name} must be a finite non-negative integer")); + } + if value > usize::MAX as f64 { + return cx.throw_type_error(format!("{name} exceeds the supported range")); + } + + Ok(value as usize) +} + +fn js_object_to_client_identity( + cx: &mut FunctionContext, + obj: Handle, +) -> NeonResult<(Option, Option)> { + let client_id = obj + .get_opt(cx, "clientId")? + .map(|value: Handle| { + let value = value.downcast::(cx).or_throw(cx)?.value(cx); + + if !value.is_finite() + || value.fract() != 0.0 + || !(0.0..=9_007_199_254_740_991.0).contains(&value) + { + return cx.throw_type_error("clientId must be a non-negative safe integer"); + } + + Ok(value as u64) + }) + .transpose()?; + let cache_key = obj + .get_opt(cx, "clientCacheKey")? + .map(|value: Handle| value.downcast::(cx).or_throw(cx)) + .transpose()? + .map(|value| value.value(cx)); + + if client_id.is_some() != cache_key.is_some() { + return cx.throw_type_error("clientId and clientCacheKey must be provided together"); + } + + Ok((client_id, cache_key)) +} + +fn js_object_to_pool_idle_timeout( + cx: &mut FunctionContext, + obj: Handle, +) -> NeonResult> { + let Some(value) = obj.get_opt::(cx, "poolIdleTimeout")? else { + return Ok(None); + }; + + if let Ok(value) = value.downcast::(cx) { + if value.value(cx) { + return cx.throw_type_error("poolIdleTimeout only accepts false or milliseconds"); + } + + return Ok(Some(PoolIdleTimeout::Disabled)); + } + + Ok(Some(PoolIdleTimeout::Millis(js_value_to_timeout_ms( + cx, value, + )?))) +} + +fn js_object_to_connection_group( + cx: &mut FunctionContext, + obj: Handle, +) -> NeonResult> { + let Some(value) = obj.get_opt::(cx, "connectionGroup")? else { + return Ok(None); + }; + + if let Ok(value) = value.downcast::(cx) { + let value = value.value(cx); + + if value.is_empty() { + return cx.throw_type_error("connectionGroup must not be empty"); + } + + return Ok(Some(ConnectionGroup::Name(value))); + } + + let value = value.downcast::(cx).or_throw(cx)?.value(cx); + + if !value.is_finite() + || value.fract() != 0.0 + || !(0.0..=9_007_199_254_740_991.0).contains(&value) + { + return cx + .throw_type_error("connectionGroup must be a string or non-negative safe integer"); + } + + Ok(Some(ConnectionGroup::Number(value as u64))) +} + fn js_value_to_non_negative_timeout_ms( cx: &mut FunctionContext, obj: Handle, @@ -281,29 +384,169 @@ pub(crate) fn js_value_to_header_tuples( Ok(tuples) } -pub(crate) fn js_object_to_request_options( +fn js_object_to_emulation( cx: &mut FunctionContext, obj: Handle, -) -> NeonResult { - let url: Handle = obj.get(cx, "url")?; - let url = url.value(cx); - - let browser_str = obj +) -> NeonResult { + let browser = obj .get_opt(cx, "browser")? .and_then(|v: Handle| v.downcast::(cx).ok()) .map(|v| v.value(cx)) - .unwrap_or_else(|| "chrome_137".to_string()); - + .unwrap_or_else(|| "chrome_149".to_string()); + let mode = obj + .get_opt(cx, "browserMode")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)) + .unwrap_or_else(|| "fixed".to_string()); + let platform = obj + .get_opt(cx, "browserPlatform")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)); + let http2 = obj + .get_opt(cx, "browserHttp2")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)); + let headers = obj + .get_opt(cx, "browserHeaders")? + .and_then(|v: Handle| v.downcast::(cx).ok()) + .map(|v| v.value(cx)); let emulation_json = obj .get_opt(cx, "emulationJson")? .and_then(|v: Handle| v.downcast::(cx).ok()) .map(|v| v.value(cx)); - let emulation = resolve_emulation( - parse_browser_emulation(&browser_str), + resolve_emulation( + parse_browser_emulation(&browser), + &mode, + platform.as_deref(), + http2, + headers, emulation_json.as_deref(), ) - .or_else(|error| cx.throw_error(format!("{:#}", error)))?; + .or_else(|error| cx.throw_error(format!("{:#}", error))) +} + +struct MultipartStreamDescriptor { + name: String, + file_name: String, + mime_type: String, + length: u64, + handle: u64, +} + +enum MultipartPartDescriptor { + Text { name: String, value: String }, + Stream(MultipartStreamDescriptor), +} + +fn js_value_to_safe_u64( + cx: &mut FunctionContext, + value: Handle, + name: &str, +) -> NeonResult { + let value = value.downcast::(cx).or_throw(cx)?.value(cx); + + if !value.is_finite() + || value.fract() != 0.0 + || !(0.0..=9_007_199_254_740_991.0).contains(&value) + { + return cx.throw_type_error(format!("{name} must be a non-negative safe integer")); + } + + Ok(value as u64) +} + +fn js_object_to_multipart_body( + cx: &mut FunctionContext, + obj: Handle, +) -> NeonResult> { + let Some(value) = obj.get_opt::(cx, "multipart")? else { + return Ok(None); + }; + let multipart = value.downcast::(cx).or_throw(cx)?; + let boundary = multipart.get::(cx, "boundary")?.value(cx); + let parts = multipart.get::(cx, "parts")?; + let mut descriptors = Vec::with_capacity(parts.len(cx) as usize); + + for (index, value) in parts.to_vec(cx)?.into_iter().enumerate() { + let part = value.downcast::(cx).or_throw(cx)?; + let kind = part.get::(cx, "kind")?.value(cx); + let name = part.get::(cx, "name")?.value(cx); + + match kind.as_str() { + "text" => { + let value = part.get::(cx, "value")?.value(cx); + descriptors.push(MultipartPartDescriptor::Text { name, value }); + } + "stream" => { + let file_name = part.get::(cx, "fileName")?.value(cx); + let mime_type = part.get::(cx, "mimeType")?.value(cx); + let length_value: Handle = part.get(cx, "length")?; + let length = js_value_to_safe_u64( + cx, + length_value, + &format!("multipart.parts[{index}].length"), + )?; + let handle_value: Handle = part.get(cx, "uploadHandle")?; + let handle = js_value_to_safe_u64( + cx, + handle_value, + &format!("multipart.parts[{index}].uploadHandle"), + )?; + + descriptors.push(MultipartPartDescriptor::Stream(MultipartStreamDescriptor { + name, + file_name, + mime_type, + length, + handle, + })); + } + _ => { + return cx.throw_type_error(format!( + "multipart.parts[{index}].kind must be 'text' or 'stream'" + )); + } + } + } + + let mut native_parts = Vec::with_capacity(descriptors.len()); + + for descriptor in descriptors { + match descriptor { + MultipartPartDescriptor::Text { name, value } => { + native_parts.push(MultipartPartOptions::Text { name, value }); + } + MultipartPartDescriptor::Stream(stream) => { + let receiver = take_upload_receiver(stream.handle) + .or_else(|error| cx.throw_error(error.to_string()))?; + + native_parts.push(MultipartPartOptions::Stream { + name: stream.name, + file_name: stream.file_name, + mime_type: stream.mime_type, + length: stream.length, + receiver, + }); + } + } + } + + Ok(Some(MultipartBodyOptions { + boundary, + parts: native_parts, + })) +} + +pub(crate) fn js_object_to_request_options( + cx: &mut FunctionContext, + obj: Handle, +) -> NeonResult { + let url: Handle = obj.get(cx, "url")?; + let url = url.value(cx); + let (client_id, client_cache_key) = js_object_to_client_identity(cx, obj)?; + + let emulation = js_object_to_emulation(cx, obj)?; let method = obj .get_opt(cx, "method")? @@ -323,10 +566,22 @@ pub(crate) fn js_object_to_request_options( .transpose()? .unwrap_or_default(); - let body = obj + let body_bytes = obj .get_opt(cx, "body")? .map(|value| js_value_to_bytes(cx, value)) .transpose()?; + let multipart = js_object_to_multipart_body(cx, obj)?; + + if body_bytes.is_some() && multipart.is_some() { + return cx.throw_type_error("body and multipart cannot both be provided"); + } + + let body = match (body_bytes, multipart) { + (Some(bytes), None) => Some(RequestBody::Bytes(bytes)), + (None, Some(multipart)) => Some(RequestBody::Multipart(multipart)), + (None, None) => None, + (Some(_), Some(_)) => unreachable!(), + }; let proxy = obj .get_opt(cx, "proxy")? @@ -341,6 +596,19 @@ pub(crate) fn js_object_to_request_options( let timeout = js_value_to_non_negative_timeout_ms(cx, obj, "timeout")?; let read_timeout = js_value_to_non_negative_timeout_ms(cx, obj, "readTimeout")?; let connect_timeout = js_value_to_non_negative_timeout_ms(cx, obj, "connectTimeout")?; + let pool_idle_timeout = js_object_to_pool_idle_timeout(cx, obj)?; + let pool_max_idle_per_host = obj + .get_opt(cx, "poolMaxIdlePerHost")? + .map(|value| js_value_to_non_negative_usize(cx, value, "poolMaxIdlePerHost")) + .transpose()?; + let pool_max_size = obj + .get_opt(cx, "poolMaxSize")? + .map(|value| js_value_to_positive_usize(cx, value, "poolMaxSize")) + .transpose()?; + let tls_session_cache_capacity = obj + .get_opt(cx, "tlsSessionCacheCapacity")? + .map(|value| js_value_to_positive_usize(cx, value, "tlsSessionCacheCapacity")) + .transpose()?; let timeout = match timeout { Some(0) => None, @@ -384,8 +652,16 @@ pub(crate) fn js_object_to_request_options( let certificate_authority = js_object_to_certificate_authority_options(cx, obj)?; let tls_debug = js_object_to_tls_debug_options(cx, obj)?; let tls_danger = js_object_to_tls_danger_options(cx, obj)?; + let connection_group = js_object_to_connection_group(cx, obj)?; + let forbid_connection_reuse = obj + .get_opt(cx, "forbidConnectionReuse")? + .and_then(|value: Handle| value.downcast::(cx).ok()) + .map(|value| value.value(cx)) + .unwrap_or(false); Ok(RequestOptions { + client_id, + client_cache_key, url, emulation, headers, @@ -398,6 +674,10 @@ pub(crate) fn js_object_to_request_options( timeout, read_timeout, connect_timeout, + pool_idle_timeout, + pool_max_idle_per_host, + pool_max_size, + tls_session_cache_capacity, disable_default_headers, compress, http1_only, @@ -407,6 +687,8 @@ pub(crate) fn js_object_to_request_options( certificate_authority, tls_debug, tls_danger, + connection_group, + forbid_connection_reuse, }) } @@ -416,23 +698,9 @@ pub(crate) fn js_object_to_websocket_options( ) -> NeonResult { let url: Handle = obj.get(cx, "url")?; let url = url.value(cx); + let (client_id, client_cache_key) = js_object_to_client_identity(cx, obj)?; - let browser_str = obj - .get_opt(cx, "browser")? - .and_then(|v: Handle| v.downcast::(cx).ok()) - .map(|v| v.value(cx)) - .unwrap_or_else(|| "chrome_137".to_string()); - - let emulation_json = obj - .get_opt(cx, "emulationJson")? - .and_then(|v: Handle| v.downcast::(cx).ok()) - .map(|v| v.value(cx)); - - let emulation = resolve_emulation( - parse_browser_emulation(&browser_str), - emulation_json.as_deref(), - ) - .or_else(|error| cx.throw_error(format!("{:#}", error)))?; + let emulation = js_object_to_emulation(cx, obj)?; let headers = obj .get_opt(cx, "headers")? @@ -466,6 +734,19 @@ pub(crate) fn js_object_to_websocket_options( Some(timeout) => Some(timeout), None => Some(30000), }; + let pool_idle_timeout = js_object_to_pool_idle_timeout(cx, obj)?; + let pool_max_idle_per_host = obj + .get_opt(cx, "poolMaxIdlePerHost")? + .map(|value| js_value_to_non_negative_usize(cx, value, "poolMaxIdlePerHost")) + .transpose()?; + let pool_max_size = obj + .get_opt(cx, "poolMaxSize")? + .map(|value| js_value_to_positive_usize(cx, value, "poolMaxSize")) + .transpose()?; + let tls_session_cache_capacity = obj + .get_opt(cx, "tlsSessionCacheCapacity")? + .map(|value| js_value_to_positive_usize(cx, value, "tlsSessionCacheCapacity")) + .transpose()?; let disable_default_headers = obj .get_opt(cx, "disableDefaultHeaders")? @@ -486,6 +767,16 @@ pub(crate) fn js_object_to_websocket_options( .and_then(|v: Handle| v.downcast::(cx).ok()) .map(|v| v.value(cx)) .unwrap_or(false); + let http_version = obj + .get_opt(cx, "httpVersion")? + .map(|value: Handle| value.downcast::(cx).or_throw(cx)) + .transpose()? + .map(|value| match value.value(cx).as_str() { + "1.1" => Ok(wreq::Version::HTTP_11), + "2" => Ok(wreq::Version::HTTP_2), + _ => cx.throw_type_error("httpVersion must be '1.1' or '2'"), + }) + .transpose()?; let read_buffer_size = obj .get_opt(cx, "readBufferSize")? .map(|v| js_value_to_positive_usize(cx, v, "readBufferSize")) @@ -517,6 +808,8 @@ pub(crate) fn js_object_to_websocket_options( let tls_danger = js_object_to_tls_danger_options(cx, obj)?; Ok(WebSocketConnectOptions { + client_id, + client_cache_key, url, emulation, headers, @@ -525,9 +818,14 @@ pub(crate) fn js_object_to_websocket_options( disable_system_proxy, dns, timeout, + pool_idle_timeout, + pool_max_idle_per_host, + pool_max_size, + tls_session_cache_capacity, disable_default_headers, protocols, force_http2, + http_version, read_buffer_size, write_buffer_size, max_write_buffer_size, diff --git a/rust/src/napi/mod.rs b/rust/src/napi/mod.rs index db48e6e..bdf2197 100644 --- a/rust/src/napi/mod.rs +++ b/rust/src/napi/mod.rs @@ -2,12 +2,14 @@ mod body; mod convert; mod profiles; mod request; +mod upload; mod websocket; use neon::prelude::*; pub fn register(cx: &mut ModuleContext) -> NeonResult<()> { request::register(cx)?; + upload::register(cx)?; body::register(cx)?; websocket::register(cx)?; profiles::register(cx)?; diff --git a/rust/src/napi/profiles.rs b/rust/src/napi/profiles.rs index 751680e..10ab0c7 100644 --- a/rust/src/napi/profiles.rs +++ b/rust/src/napi/profiles.rs @@ -1,8 +1,7 @@ use neon::prelude::*; use std::collections::HashMap; use std::sync::OnceLock; -use strum::VariantArray; -use wreq_util::Emulation as BrowserEmulation; +use wreq_util::Profile as BrowserEmulation; static PROFILE_NAMES: OnceLock> = OnceLock::new(); static PROFILE_MAP: OnceLock> = OnceLock::new(); @@ -36,7 +35,7 @@ pub(crate) fn parse_browser_emulation(browser: &str) -> BrowserEmulation { profile_map() .get(browser) .copied() - .unwrap_or(BrowserEmulation::Chrome137) + .unwrap_or(BrowserEmulation::Chrome149) } fn get_profiles(mut cx: FunctionContext) -> JsResult { diff --git a/rust/src/napi/request.rs b/rust/src/napi/request.rs index 2f80564..457df72 100644 --- a/rust/src/napi/request.rs +++ b/rust/src/napi/request.rs @@ -1,4 +1,5 @@ use crate::napi::convert::{js_object_to_request_options, response_to_js_object}; +use crate::store::client_store::remove_clients; use crate::store::request_store::{ cancel_request as cancel_request_handle, insert_request, remove_request, }; @@ -46,8 +47,22 @@ fn cancel_request(mut cx: FunctionContext) -> JsResult { Ok(cx.boolean(cancel_request_handle(handle))) } +fn release_client(mut cx: FunctionContext) -> JsResult { + let owner = cx.argument::(0)?.value(&mut cx); + + if !owner.is_finite() + || owner.fract() != 0.0 + || !(0.0..=9_007_199_254_740_991.0).contains(&owner) + { + return cx.throw_type_error("client id must be a non-negative safe integer"); + } + + Ok(cx.boolean(remove_clients(owner as u64))) +} + pub fn register(cx: &mut ModuleContext) -> NeonResult<()> { cx.export_function("request", request)?; cx.export_function("cancelRequest", cancel_request)?; + cx.export_function("releaseClient", release_client)?; Ok(()) } diff --git a/rust/src/napi/upload.rs b/rust/src/napi/upload.rs new file mode 100644 index 0000000..a8b3368 --- /dev/null +++ b/rust/src/napi/upload.rs @@ -0,0 +1,90 @@ +use crate::store::runtime::runtime; +use crate::store::upload_store::{create_upload, finish_upload, upload_sender}; +use neon::prelude::*; +use neon::types::buffer::TypedArray; +use neon::types::JsBuffer; +use std::io; + +fn parse_upload_handle(cx: &mut FunctionContext, index: usize) -> NeonResult { + let value = cx.argument::(index)?.value(cx); + + if !value.is_finite() + || value.fract() != 0.0 + || !(0.0..=9_007_199_254_740_991.0).contains(&value) + { + return cx.throw_type_error("upload handle must be a non-negative safe integer"); + } + + Ok(value as u64) +} + +fn create_upload_js(mut cx: FunctionContext) -> JsResult { + let handle = create_upload(); + Ok(cx.number(handle as f64)) +} + +fn write_upload_chunk_js(mut cx: FunctionContext) -> JsResult { + let handle = parse_upload_handle(&mut cx, 0)?; + let buffer = cx.argument::(1)?; + let bytes = buffer.as_slice(&cx).to_vec(); + let sender = upload_sender(handle).or_else(|error| cx.throw_error(error.to_string()))?; + + let channel = cx.channel(); + let (deferred, promise) = cx.promise(); + + runtime().spawn(async move { + let result = sender + .send(Ok(bytes)) + .await + .map_err(|_| anyhow::anyhow!("Multipart upload stream {handle} was closed")); + + if result.is_err() { + finish_upload(handle); + } + + deferred.settle_with(&channel, move |mut cx| match result { + Ok(()) => Ok(cx.undefined()), + Err(error) => cx.throw_error(error.to_string()), + }); + }); + + Ok(promise) +} + +fn fail_upload_js(mut cx: FunctionContext) -> JsResult { + let handle = parse_upload_handle(&mut cx, 0)?; + let message = cx.argument::(1)?.value(&mut cx); + let sender = upload_sender(handle).or_else(|error| cx.throw_error(error.to_string()))?; + + let channel = cx.channel(); + let (deferred, promise) = cx.promise(); + + runtime().spawn(async move { + let result = sender + .send(Err(io::Error::other(message))) + .await + .map_err(|_| anyhow::anyhow!("Multipart upload stream {handle} was closed")); + + finish_upload(handle); + + deferred.settle_with(&channel, move |mut cx| match result { + Ok(()) => Ok(cx.undefined()), + Err(error) => cx.throw_error(error.to_string()), + }); + }); + + Ok(promise) +} + +fn finish_upload_js(mut cx: FunctionContext) -> JsResult { + let handle = parse_upload_handle(&mut cx, 0)?; + Ok(cx.boolean(finish_upload(handle))) +} + +pub fn register(cx: &mut ModuleContext) -> NeonResult<()> { + cx.export_function("createUpload", create_upload_js)?; + cx.export_function("writeUploadChunk", write_upload_chunk_js)?; + cx.export_function("failUpload", fail_upload_js)?; + cx.export_function("finishUpload", finish_upload_js)?; + Ok(()) +} diff --git a/rust/src/store/body_store.rs b/rust/src/store/body_store.rs index fb643f3..a8a309a 100644 --- a/rust/src/store/body_store.rs +++ b/rust/src/store/body_store.rs @@ -1,5 +1,6 @@ use crate::store::runtime::runtime; use anyhow::{Context, Result}; +use http_body_util::BodyExt; use std::collections::HashMap; use std::sync::{ atomic::{AtomicU64, Ordering}, @@ -51,10 +52,18 @@ pub fn read_body_chunk(handle: u64, _size: usize) -> Result<(Vec, bool)> { let body = get_body(handle)?; let chunk = runtime().block_on(async { let mut body = body.lock().await; - body.response - .chunk() - .await - .context("Failed to read response body chunk") + loop { + match body.response.frame().await { + Some(Ok(frame)) => { + if let Ok(data) = frame.into_data() { + break Ok(Some(data)); + } + } + Some(Err(error)) => break Err(error), + None => break Ok(None), + } + } + .context("Failed to read response body chunk") })?; let Some(chunk) = chunk else { @@ -68,3 +77,14 @@ pub fn read_body_chunk(handle: u64, _size: usize) -> Result<(Vec, bool)> { pub fn cancel_body(handle: u64) -> bool { remove_body(handle).is_some() } + +pub fn forbid_body_recycle(handle: u64) -> Result { + let body = get_body(handle)?; + let body = body + .try_lock() + .map_err(|_| anyhow::anyhow!("Response body is currently being consumed"))?; + + body.response.forbid_recycle(); + + Ok(true) +} diff --git a/rust/src/store/client_store.rs b/rust/src/store/client_store.rs new file mode 100644 index 0000000..41e2e67 --- /dev/null +++ b/rust/src/store/client_store.rs @@ -0,0 +1,35 @@ +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; +use wreq::Client; + +type ClientVariants = HashMap; + +static CLIENTS: OnceLock>> = OnceLock::new(); + +fn clients() -> &'static RwLock> { + CLIENTS.get_or_init(|| RwLock::new(HashMap::new())) +} + +pub fn get_client(owner: u64, key: &str) -> Option { + clients() + .read() + .unwrap_or_else(|error| error.into_inner()) + .get(&owner) + .and_then(|variants| variants.get(key)) + .cloned() +} + +pub fn insert_client(owner: u64, key: String, client: Client) -> Client { + let mut clients = clients().write().unwrap_or_else(|error| error.into_inner()); + let variants = clients.entry(owner).or_default(); + + variants.entry(key).or_insert(client).clone() +} + +pub fn remove_clients(owner: u64) -> bool { + clients() + .write() + .unwrap_or_else(|error| error.into_inner()) + .remove(&owner) + .is_some() +} diff --git a/rust/src/store/mod.rs b/rust/src/store/mod.rs index 47456a7..a1fd087 100644 --- a/rust/src/store/mod.rs +++ b/rust/src/store/mod.rs @@ -1,4 +1,6 @@ pub mod body_store; +pub mod client_store; pub mod request_store; pub mod runtime; +pub mod upload_store; pub mod websocket_store; diff --git a/rust/src/store/upload_store.rs b/rust/src/store/upload_store.rs new file mode 100644 index 0000000..fa2c55e --- /dev/null +++ b/rust/src/store/upload_store.rs @@ -0,0 +1,71 @@ +use anyhow::{anyhow, Result}; +use std::collections::HashMap; +use std::io; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Mutex, OnceLock, +}; +use tokio::sync::mpsc; + +const UPLOAD_BUFFERED_CHUNKS: usize = 2; + +pub type UploadChunk = Result, io::Error>; +pub type UploadReceiver = mpsc::Receiver; + +struct UploadStream { + sender: mpsc::Sender, + receiver: Option, +} + +static NEXT_UPLOAD_HANDLE: AtomicU64 = AtomicU64::new(1); +static UPLOAD_STORE: OnceLock>> = OnceLock::new(); + +fn upload_store() -> &'static Mutex> { + UPLOAD_STORE.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub fn create_upload() -> u64 { + let handle = NEXT_UPLOAD_HANDLE.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = mpsc::channel(UPLOAD_BUFFERED_CHUNKS); + + upload_store() + .lock() + .expect("upload store poisoned") + .insert( + handle, + UploadStream { + sender, + receiver: Some(receiver), + }, + ); + + handle +} + +pub fn take_upload_receiver(handle: u64) -> Result { + upload_store() + .lock() + .expect("upload store poisoned") + .get_mut(&handle) + .ok_or_else(|| anyhow!("Unknown multipart upload stream: {handle}"))? + .receiver + .take() + .ok_or_else(|| anyhow!("Multipart upload stream {handle} is already attached")) +} + +pub fn upload_sender(handle: u64) -> Result> { + upload_store() + .lock() + .expect("upload store poisoned") + .get(&handle) + .map(|stream| stream.sender.clone()) + .ok_or_else(|| anyhow!("Unknown multipart upload stream: {handle}")) +} + +pub fn finish_upload(handle: u64) -> bool { + upload_store() + .lock() + .expect("upload store poisoned") + .remove(&handle) + .is_some() +} diff --git a/rust/src/transport/client.rs b/rust/src/transport/client.rs new file mode 100644 index 0000000..da1a267 --- /dev/null +++ b/rust/src/transport/client.rs @@ -0,0 +1,216 @@ +use crate::store::client_store::{get_client, insert_client}; +use crate::transport::dns::configure_client_builder as configure_dns; +use crate::transport::tls::configure_client_builder as configure_tls; +use crate::transport::types::{ + CertificateAuthorityOptions, DnsOptions, LocalBindOptions, PoolIdleTimeout, RequestOptions, + TlsDangerOptions, TlsDebugOptions, TlsIdentityOptions, WebSocketConnectOptions, +}; +use anyhow::{Context, Result}; +use std::time::Duration; +use wreq::tls::session::LruTlsSessionCache; +use wreq::{Client, Emulation}; + +#[derive(Clone)] +struct ClientConfig { + owner: Option, + cache_key: Option, + emulation: Emulation, + proxy: Option, + disable_system_proxy: bool, + dns: Option, + timeout: Option, + connect_timeout: Option, + pool_idle_timeout: Option, + pool_max_idle_per_host: Option, + pool_max_size: Option, + tls_session_cache_capacity: Option, + http1_only: bool, + http2_only: bool, + local_bind: Option, + tls_identity: Option, + certificate_authority: Option, + tls_debug: Option, + tls_danger: Option, +} + +impl From<&RequestOptions> for ClientConfig { + fn from(options: &RequestOptions) -> Self { + Self { + owner: options.client_id, + cache_key: options.client_cache_key.clone(), + emulation: options.emulation.clone(), + proxy: options.proxy.clone(), + disable_system_proxy: options.disable_system_proxy, + dns: options.dns.clone(), + timeout: None, + connect_timeout: options.connect_timeout, + pool_idle_timeout: options.pool_idle_timeout.clone(), + pool_max_idle_per_host: options.pool_max_idle_per_host, + pool_max_size: options.pool_max_size, + tls_session_cache_capacity: options.tls_session_cache_capacity, + http1_only: options.http1_only, + http2_only: options.http2_only, + local_bind: options.local_bind.clone(), + tls_identity: options.tls_identity.clone(), + certificate_authority: options.certificate_authority.clone(), + tls_debug: options.tls_debug.clone(), + tls_danger: options.tls_danger.clone(), + } + } +} + +impl From<&WebSocketConnectOptions> for ClientConfig { + fn from(options: &WebSocketConnectOptions) -> Self { + Self { + owner: options.client_id, + cache_key: options.client_cache_key.clone(), + emulation: options.emulation.clone(), + proxy: options.proxy.clone(), + disable_system_proxy: options.disable_system_proxy, + dns: options.dns.clone(), + timeout: options.timeout, + connect_timeout: None, + pool_idle_timeout: options.pool_idle_timeout.clone(), + pool_max_idle_per_host: options.pool_max_idle_per_host, + pool_max_size: options.pool_max_size, + tls_session_cache_capacity: options.tls_session_cache_capacity, + http1_only: false, + http2_only: false, + local_bind: options.local_bind.clone(), + tls_identity: options.tls_identity.clone(), + certificate_authority: options.certificate_authority.clone(), + tls_debug: options.tls_debug.clone(), + tls_danger: options.tls_danger.clone(), + } + } +} + +pub async fn request_client(options: &RequestOptions) -> Result { + resolve_client(ClientConfig::from(options)).await +} + +pub async fn websocket_client(options: &WebSocketConnectOptions) -> Result { + resolve_client(ClientConfig::from(options)).await +} + +async fn resolve_client(config: ClientConfig) -> Result { + if let (Some(owner), Some(key)) = (config.owner, config.cache_key.as_deref()) { + if let Some(client) = get_client(owner, key) { + return Ok(client); + } + } + + let owner = config.owner; + let cache_key = config.cache_key.clone(); + let client = build_client(config).await?; + + Ok(match (owner, cache_key) { + (Some(owner), Some(key)) => insert_client(owner, key, client), + _ => client, + }) +} + +async fn build_client(config: ClientConfig) -> Result { + let mut builder = Client::builder().emulation(config.emulation); + + if config.disable_system_proxy { + builder = builder.no_proxy(); + } else if let Some(proxy_url) = &config.proxy { + let proxy = wreq::Proxy::all(proxy_url).context("Failed to create proxy")?; + builder = builder.proxy(proxy); + } + + builder = configure_dns(builder, config.dns).await?; + builder = configure_tls( + builder, + config.tls_identity, + config.certificate_authority, + config.tls_debug, + config.tls_danger, + )?; + + if let Some(timeout) = config.timeout { + builder = builder.timeout(Duration::from_millis(timeout)); + } + if let Some(connect_timeout) = config.connect_timeout { + builder = builder.connect_timeout(Duration::from_millis(connect_timeout)); + } + if let Some(pool_idle_timeout) = config.pool_idle_timeout { + builder = match pool_idle_timeout { + PoolIdleTimeout::Disabled => builder.pool_idle_timeout(None), + PoolIdleTimeout::Millis(value) => { + builder.pool_idle_timeout(Some(Duration::from_millis(value))) + } + }; + } + if let Some(max) = config.pool_max_idle_per_host { + builder = builder.pool_max_idle_per_host(max); + } + if let Some(max) = config.pool_max_size { + builder = builder.pool_max_size(max); + } + if let Some(capacity) = config.tls_session_cache_capacity { + builder = builder.tls_session_cache(LruTlsSessionCache::new(capacity)); + } + if config.http1_only { + builder = builder.http1_only(); + } + if config.http2_only { + builder = builder.http2_only(); + } + + builder = configure_local_bind(builder, config.local_bind); + + builder.build().context("Failed to build HTTP client") +} + +fn configure_local_bind( + mut builder: wreq::ClientBuilder, + local_bind: Option, +) -> wreq::ClientBuilder { + let Some(local_bind) = local_bind else { + return builder; + }; + + if let Some(address) = local_bind.address { + builder = builder.local_address(address); + } + if local_bind.ipv4.is_some() || local_bind.ipv6.is_some() { + builder = builder.local_addresses(local_bind.ipv4, local_bind.ipv6); + } + if let Some(interface) = local_bind.interface { + #[cfg(any( + target_os = "android", + target_os = "fuchsia", + target_os = "illumos", + target_os = "ios", + target_os = "linux", + target_os = "macos", + target_os = "solaris", + target_os = "tvos", + target_os = "visionos", + target_os = "watchos", + ))] + { + builder = builder.interface(interface); + } + + #[cfg(not(any( + target_os = "android", + target_os = "fuchsia", + target_os = "illumos", + target_os = "ios", + target_os = "linux", + target_os = "macos", + target_os = "solaris", + target_os = "tvos", + target_os = "visionos", + target_os = "watchos", + )))] + { + let _ = interface; + } + } + + builder +} diff --git a/rust/src/transport/mod.rs b/rust/src/transport/mod.rs index 09e8395..f91ed73 100644 --- a/rust/src/transport/mod.rs +++ b/rust/src/transport/mod.rs @@ -1,3 +1,4 @@ +mod client; mod cookies; mod dns; mod headers; diff --git a/rust/src/transport/request.rs b/rust/src/transport/request.rs index de5b5cf..cc625c3 100644 --- a/rust/src/transport/request.rs +++ b/rust/src/transport/request.rs @@ -1,118 +1,86 @@ use crate::store::body_store::store_body; +use crate::transport::client::request_client; 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, ResponseTlsInfo}; +use crate::transport::types::{ + ConnectionGroup, MultipartBodyOptions, MultipartPartOptions, RequestBody, RequestOptions, + Response, ResponseTlsInfo, +}; use anyhow::{Context, Result}; use std::time::Duration; -use wreq::{redirect, tls::TlsInfo, Method}; +use tokio_stream::wrappers::ReceiverStream; +use wreq::{ + multipart::{Form, Part}, + redirect, + tls::TlsInfo, + Body, Group, Method, +}; + +fn build_multipart(options: MultipartBodyOptions) -> Result
{ + let mut form = Form::with_boundary(options.boundary).percent_encode_noop(); + + for part in options.parts { + match part { + MultipartPartOptions::Text { name, value } => { + form = form.text(name, value); + } + MultipartPartOptions::Stream { + name, + file_name, + mime_type, + length, + receiver, + } => { + let stream = ReceiverStream::new(receiver); + let body = Body::wrap_stream(stream); + let part = Part::stream_with_length(body, length) + .file_name(file_name) + .mime_str(&mime_type) + .with_context(|| format!("Invalid multipart MIME type: {mime_type}"))?; + + form = form.part(name, part); + } + } + } + + Ok(form) +} pub async fn make_request(options: RequestOptions) -> Result { + let client = request_client(&options).await?; let RequestOptions { + client_id: _, + client_cache_key: _, url, - emulation, + emulation: _, headers, orig_headers, method, body, - proxy, - disable_system_proxy, - dns, + proxy: _, + disable_system_proxy: _, + dns: _, timeout, read_timeout, - connect_timeout, + connect_timeout: _, + pool_idle_timeout: _, + pool_max_idle_per_host: _, + pool_max_size: _, + tls_session_cache_capacity: _, disable_default_headers, compress, - http1_only, - http2_only, - local_bind, - tls_identity, - certificate_authority, - tls_debug, - tls_danger, + http1_only: _, + http2_only: _, + local_bind: _, + tls_identity: _, + certificate_authority: _, + tls_debug: _, + tls_danger: _, + connection_group, + forbid_connection_reuse, } = options; - let mut client_builder = wreq::Client::builder() - .emulation(emulation) - .cookie_store(true); - - 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).await?; - client_builder = configure_client_builder( - client_builder, - tls_identity, - certificate_authority, - tls_debug, - tls_danger, - )?; - - if let Some(connect_timeout) = connect_timeout { - client_builder = client_builder.connect_timeout(Duration::from_millis(connect_timeout)); - } - - if http1_only { - client_builder = client_builder.http1_only(); - } - - if http2_only { - client_builder = client_builder.http2_only(); - } - - if let Some(local_bind) = local_bind { - if let Some(address) = local_bind.address { - client_builder = client_builder.local_address(address); - } - - if local_bind.ipv4.is_some() || local_bind.ipv6.is_some() { - client_builder = client_builder.local_addresses(local_bind.ipv4, local_bind.ipv6); - } - - if let Some(interface) = local_bind.interface { - #[cfg(any( - target_os = "android", - target_os = "fuchsia", - target_os = "illumos", - target_os = "ios", - target_os = "linux", - target_os = "macos", - target_os = "solaris", - target_os = "tvos", - target_os = "visionos", - target_os = "watchos", - ))] - { - client_builder = client_builder.interface(interface); - } - - #[cfg(not(any( - target_os = "android", - target_os = "fuchsia", - target_os = "illumos", - target_os = "ios", - target_os = "linux", - target_os = "macos", - target_os = "solaris", - target_os = "tvos", - target_os = "visionos", - target_os = "watchos", - )))] - { - let _ = interface; - } - } - } - let orig_headers = build_orig_header_map(&orig_headers); - let client = client_builder - .build() - .context("Failed to build HTTP client")?; let method = if method.is_empty() { "GET" } else { &method }; let parsed_method = Method::from_bytes(method.as_bytes()) @@ -120,6 +88,13 @@ pub async fn make_request(options: RequestOptions) -> Result { let mut request = client.request(parsed_method, &url); + if let Some(group) = connection_group { + request = request.group(match group { + ConnectionGroup::Name(name) => Group::new(name), + ConnectionGroup::Number(number) => Group::new(number), + }); + } + for (key, value) in &headers { request = request.header(key, value); } @@ -129,7 +104,10 @@ pub async fn make_request(options: RequestOptions) -> Result { } if let Some(body) = body { - request = request.body(body); + request = match body { + RequestBody::Bytes(bytes) => request.body(bytes), + RequestBody::Multipart(options) => request.multipart(build_multipart(options)?), + }; } if let Some(timeout) = timeout { @@ -150,6 +128,10 @@ pub async fn make_request(options: RequestOptions) -> Result { .await .with_context(|| format!("{} {}", method, url))?; + if forbid_connection_reuse { + response.forbid_recycle(); + } + let tls_info = response .extensions() .get::() diff --git a/rust/src/transport/tls.rs b/rust/src/transport/tls.rs index b97327b..e5b140e 100644 --- a/rust/src/transport/tls.rs +++ b/rust/src/transport/tls.rs @@ -4,7 +4,7 @@ use crate::transport::types::{ }; use anyhow::{Context, Result}; use wreq::{ - tls::{CertStore, Identity, KeyLog}, + tls::{keylog::KeyLog, trust::CertStore, trust::Identity}, ClientBuilder, }; @@ -16,11 +16,11 @@ pub fn configure_client_builder( tls_danger: Option, ) -> Result { if let Some(tls_identity) = tls_identity { - builder = builder.identity(build_identity(tls_identity)?); + builder = builder.tls_identity(build_identity(tls_identity)?); } if let Some(certificate_authority) = certificate_authority { - builder = builder.cert_store(build_cert_store(certificate_authority)?); + builder = builder.tls_cert_store(build_cert_store(certificate_authority)?); } if let Some(tls_debug) = tls_debug { @@ -29,7 +29,7 @@ pub fn configure_client_builder( } if let Some(keylog) = tls_debug.keylog { - builder = builder.keylog(match keylog { + builder = builder.tls_keylog(match keylog { TlsKeylogOptions::FromEnv => KeyLog::from_env(), TlsKeylogOptions::File { path } => KeyLog::from_file(path), }); @@ -38,11 +38,11 @@ pub fn configure_client_builder( if let Some(tls_danger) = tls_danger { if let Some(cert_verification) = tls_danger.cert_verification { - builder = builder.cert_verification(cert_verification); + builder = builder.tls_cert_verification(cert_verification); } if let Some(verify_hostname) = tls_danger.verify_hostname { - builder = builder.verify_hostname(verify_hostname); + builder = builder.tls_verify_hostname(verify_hostname); } if let Some(sni) = tls_danger.sni { diff --git a/rust/src/transport/types.rs b/rust/src/transport/types.rs index 601e659..b8ed124 100644 --- a/rust/src/transport/types.rs +++ b/rust/src/transport/types.rs @@ -1,3 +1,4 @@ +use crate::store::upload_store::UploadReceiver; use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use wreq::Emulation; @@ -62,19 +63,64 @@ pub struct LocalBindOptions { } #[derive(Debug, Clone)] +pub enum PoolIdleTimeout { + Disabled, + Millis(u64), +} + +#[derive(Debug, Clone)] +pub enum ConnectionGroup { + Name(String), + Number(u64), +} + +#[derive(Debug)] +pub enum RequestBody { + Bytes(Vec), + Multipart(MultipartBodyOptions), +} + +#[derive(Debug)] +pub struct MultipartBodyOptions { + pub boundary: String, + pub parts: Vec, +} + +#[derive(Debug)] +pub enum MultipartPartOptions { + Text { + name: String, + value: String, + }, + Stream { + name: String, + file_name: String, + mime_type: String, + length: u64, + receiver: UploadReceiver, + }, +} + +#[derive(Debug)] pub struct RequestOptions { + pub client_id: Option, + pub client_cache_key: Option, pub url: String, pub emulation: Emulation, 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: Option, pub read_timeout: Option, pub connect_timeout: Option, + pub pool_idle_timeout: Option, + pub pool_max_idle_per_host: Option, + pub pool_max_size: Option, + pub tls_session_cache_capacity: Option, pub disable_default_headers: bool, pub compress: bool, pub http1_only: bool, @@ -84,6 +130,8 @@ pub struct RequestOptions { pub certificate_authority: Option, pub tls_debug: Option, pub tls_danger: Option, + pub connection_group: Option, + pub forbid_connection_reuse: bool, } #[derive(Debug, Clone)] @@ -99,6 +147,8 @@ pub struct Response { #[derive(Debug, Clone)] pub struct WebSocketConnectOptions { + pub client_id: Option, + pub client_cache_key: Option, pub url: String, pub emulation: Emulation, pub headers: Vec<(String, String)>, @@ -107,9 +157,14 @@ pub struct WebSocketConnectOptions { pub disable_system_proxy: bool, pub dns: Option, pub timeout: Option, + pub pool_idle_timeout: Option, + pub pool_max_idle_per_host: Option, + pub pool_max_size: Option, + pub tls_session_cache_capacity: Option, pub disable_default_headers: bool, pub protocols: Vec, pub force_http2: bool, + pub http_version: Option, pub read_buffer_size: Option, pub write_buffer_size: Option, pub max_write_buffer_size: Option, diff --git a/rust/src/transport/websocket.rs b/rust/src/transport/websocket.rs index ca14634..030f9d7 100644 --- a/rust/src/transport/websocket.rs +++ b/rust/src/transport/websocket.rs @@ -1,13 +1,12 @@ 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::client::websocket_client; 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; use wreq::ws::message::{CloseCode, CloseFrame, Message}; use wreq::ws::WebSocket; +use wreq::Version; pub fn connect_websocket(options: WebSocketConnectOptions) -> Result { runtime().block_on(make_websocket(options)) @@ -133,103 +132,39 @@ async fn run_websocket_task( } async fn make_websocket(options: WebSocketConnectOptions) -> Result { + let client = websocket_client(&options).await?; let WebSocketConnectOptions { + client_id: _, + client_cache_key: _, url, - emulation, + emulation: _, headers, orig_headers, - proxy, - disable_system_proxy, - dns, - timeout, + proxy: _, + disable_system_proxy: _, + dns: _, + timeout: _, + pool_idle_timeout: _, + pool_max_idle_per_host: _, + pool_max_size: _, + tls_session_cache_capacity: _, disable_default_headers, protocols, force_http2, + http_version, read_buffer_size, write_buffer_size, max_write_buffer_size, accept_unmasked_frames, max_frame_size, max_message_size, - local_bind, - tls_identity, - certificate_authority, - tls_debug, - tls_danger, + local_bind: _, + tls_identity: _, + certificate_authority: _, + tls_debug: _, + tls_danger: _, } = options; - let mut client_builder = wreq::Client::builder() - .emulation(emulation) - .cookie_store(true); - - if let Some(timeout) = timeout { - client_builder = client_builder.timeout(Duration::from_millis(timeout)); - } - - 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).await?; - client_builder = configure_client_builder( - client_builder, - tls_identity, - certificate_authority, - tls_debug, - tls_danger, - )?; - - if let Some(local_bind) = local_bind { - if let Some(address) = local_bind.address { - client_builder = client_builder.local_address(address); - } - - if local_bind.ipv4.is_some() || local_bind.ipv6.is_some() { - client_builder = client_builder.local_addresses(local_bind.ipv4, local_bind.ipv6); - } - - if let Some(interface) = local_bind.interface { - #[cfg(any( - target_os = "android", - target_os = "fuchsia", - target_os = "illumos", - target_os = "ios", - target_os = "linux", - target_os = "macos", - target_os = "solaris", - target_os = "tvos", - target_os = "visionos", - target_os = "watchos", - ))] - { - client_builder = client_builder.interface(interface); - } - - #[cfg(not(any( - target_os = "android", - target_os = "fuchsia", - target_os = "illumos", - target_os = "ios", - target_os = "linux", - target_os = "macos", - target_os = "solaris", - target_os = "tvos", - target_os = "visionos", - target_os = "watchos", - )))] - { - let _ = interface; - } - } - } - - let client = client_builder - .build() - .context("Failed to build WebSocket client")?; - let mut request = client.websocket(&url); let orig_headers = build_orig_header_map(&orig_headers); for (key, value) in &headers { @@ -242,8 +177,10 @@ async fn make_websocket(options: WebSocketConnectOptions) -> Result ${update.latestVersion}` ); + updateLockfile(update.dependency, update.latestVersion); } } diff --git a/src/client/index.ts b/src/client/index.ts index f4c1702..743da93 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,6 +1,3 @@ -import { Headers } from '../headers'; -import { mergeHooks } from '../hooks'; -import { fetch } from '../http/fetch'; import type { Client, ClientDefaults, @@ -11,7 +8,20 @@ import type { WebSocketInit, WreqInit, } from '../types'; -import { websocket } from '../websocket'; +import { Headers } from '../headers'; +import { mergeHooks } from '../hooks'; +import { fetchWithNativeClient } from '../http/fetch'; +import { nativeReleaseClient } from '../native'; +import { websocketWithNativeClient } from '../websocket'; + +let nextNativeClientId = 1; +const clientFinalizer = new FinalizationRegistry((clientId) => { + try { + nativeReleaseClient(clientId); + } catch { + // Native bindings may already be unavailable during process shutdown. + } +}); function mergeHeaders(...sources: Array): HeaderTuple[] | undefined { const merged = new Headers(); @@ -115,6 +125,9 @@ function mergeDefaults(base: ClientDefaults, override?: ClientDefaults): ClientD class WreqClient implements Client { readonly defaults: ClientDefaults; + readonly #nativeClientId = nextNativeClientId++; + readonly #finalizerToken = {}; + #closed = false; constructor(defaults: ClientDefaults = {}) { this.defaults = { @@ -124,9 +137,13 @@ class WreqClient implements Client { context: mergeContext(defaults.context), retry: mergeRetry(defaults.retry), }; + + clientFinalizer.register(this, this.#nativeClientId, this.#finalizerToken); } async fetch(input: RequestInput, init?: WreqInit) { + this.#assertOpen(); + const merged: WreqInit = { ...this.defaults, ...init, @@ -137,10 +154,12 @@ class WreqClient implements Client { retry: mergeRetry(this.defaults.retry, init?.retry), }; - return fetch(input, merged); + return fetchWithNativeClient(input, merged, this.#nativeClientId); } async websocket(input: string | URL, init?: WebSocketInit) { + this.#assertOpen(); + const merged: WebSocketInit = { ...this.defaults, ...init, @@ -148,7 +167,7 @@ class WreqClient implements Client { query: mergeQuery(this.defaults.query, init?.query), }; - return websocket(input, merged); + return websocketWithNativeClient(input, merged, this.#nativeClientId); } async get(input: RequestInput, init?: Omit) { @@ -192,8 +211,26 @@ class WreqClient implements Client { } extend(defaults: ClientDefaults): Client { + this.#assertOpen(); + return new WreqClient(mergeDefaults(this.defaults, defaults)); } + + close(): void { + if (this.#closed) { + return; + } + + this.#closed = true; + clientFinalizer.unregister(this.#finalizerToken); + nativeReleaseClient(this.#nativeClientId); + } + + #assertOpen(): void { + if (this.#closed) { + throw new Error('Client is closed'); + } + } } /** Creates a reusable client with mergeable default options. */ diff --git a/src/config/generated/browser-profiles.ts b/src/config/generated/browser-profiles.ts index dcd5011..845ab85 100644 --- a/src/config/generated/browser-profiles.ts +++ b/src/config/generated/browser-profiles.ts @@ -41,6 +41,8 @@ export const BROWSER_PROFILES = [ 'chrome_145', 'chrome_146', 'chrome_147', + 'chrome_148', + 'chrome_149', 'edge_101', 'edge_122', 'edge_127', @@ -59,6 +61,7 @@ export const BROWSER_PROFILES = [ 'edge_145', 'edge_146', 'edge_147', + 'edge_148', 'opera_116', 'opera_117', 'opera_118', @@ -74,6 +77,7 @@ export const BROWSER_PROFILES = [ 'opera_128', 'opera_129', 'opera_130', + 'opera_131', 'firefox_109', 'firefox_117', 'firefox_128', @@ -92,6 +96,8 @@ export const BROWSER_PROFILES = [ 'firefox_147', 'firefox_148', 'firefox_149', + 'firefox_150', + 'firefox_151', 'safari_ios_17_2', 'safari_ios_17_4_1', 'safari_ios_16_5', @@ -115,6 +121,8 @@ export const BROWSER_PROFILES = [ 'safari_26', 'safari_26_1', 'safari_26_2', + 'safari_26_3', + 'safari_26_4', 'safari_ipad_26', 'safari_ipad_26_2', 'safari_ios_26', diff --git a/src/config/network.ts b/src/config/network.ts index 0ec02ec..64e3c7e 100644 --- a/src/config/network.ts +++ b/src/config/network.ts @@ -1,4 +1,3 @@ -import { isIP } from 'node:net'; import type { DnsOptions, NativeDnsOptions, @@ -6,6 +5,7 @@ import type { WebSocketInit, WreqInit, } from '../types'; +import { isIP } from 'node:net'; export function normalizeProxyOptions(proxy: WreqInit['proxy']): { proxy?: string; diff --git a/src/config/tls.ts b/src/config/tls.ts index 60a2238..40f5e61 100644 --- a/src/config/tls.ts +++ b/src/config/tls.ts @@ -1,4 +1,3 @@ -import { Buffer } from 'node:buffer'; import type { CertificateAuthority, NativeCertificateAuthority, @@ -11,6 +10,7 @@ import type { TlsDebugOptions, TlsIdentity, } from '../types'; +import { Buffer } from 'node:buffer'; function toBuffer(input: TlsDataInput | TlsBinaryInput): Buffer { if (Buffer.isBuffer(input)) { diff --git a/src/http/body/bytes.ts b/src/http/body/bytes.ts index 82a757f..4bd756a 100644 --- a/src/http/body/bytes.ts +++ b/src/http/body/bytes.ts @@ -1,5 +1,12 @@ +import type { BodyInit, NativeMultipartStreamPart, NativeMultipartUpload } from '../../types'; import { Buffer } from 'node:buffer'; -import type { BodyInit } from '../../types'; +import { randomBytes } from 'node:crypto'; +import { + nativeCreateUpload, + nativeFailUpload, + nativeFinishUpload, + nativeWriteUploadChunk, +} from '../../native'; const FORM_DATA_PLACEHOLDER_URL = 'http://node-wreq.invalid/'; @@ -22,7 +29,7 @@ export function cloneFormData(body: FormData): FormData { } if (isFileValue(value)) { - cloned.append(name, value, value.name); + cloned.append(name, value); continue; } @@ -33,15 +40,303 @@ export function cloneFormData(body: FormData): FormData { return cloned; } -export function createMultipartRequest(body: FormData): globalThis.Request { +const WEBKIT_BOUNDARY_PREFIX = '----WebKitFormBoundary'; +const BOUNDARY_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789AB'; +const BOUNDARY_PATTERN = /^[0-9A-Za-z'()+_,./:=?-]{1,70}$/; + +function generateWebKitBoundary(): string { + const random = randomBytes(16); + let suffix = ''; + + for (const byte of random) { + suffix += BOUNDARY_ALPHABET[byte & 0x3f]; + } + + return `${WEBKIT_BOUNDARY_PREFIX}${suffix}`; +} + +function normalizeLineEndings(value: string): string { + return value.replace(/\r\n|\r|\n/g, '\r\n'); +} + +function escapeMultipartParameter(value: string): string { + return normalizeLineEndings(value) + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A') + .replace(/"/g, '%22'); +} + +function validateMultipartBoundary(boundary: string): void { + if (!BOUNDARY_PATTERN.test(boundary)) { + throw new TypeError( + 'multipartBoundary must contain 1-70 ASCII boundary characters without spaces' + ); + } +} + +function findSequence(source: Uint8Array, needle: Uint8Array, offset: number): number { + outer: for (let index = offset; index <= source.length - needle.length; index += 1) { + for (let part = 0; part < needle.length; part += 1) { + if (source[index + part] !== needle[part]) { + continue outer; + } + } + + return index; + } + + return -1; +} + +function replaceBoundaryBytes( + source: Uint8Array, + sourceBoundary: string, + targetBoundary: string +): Uint8Array { + if (sourceBoundary === targetBoundary) { + return source; + } + + const needle = Buffer.from(`--${sourceBoundary}`); + const replacement = Buffer.from(`--${targetBoundary}`); + const matches: number[] = []; + let offset = 0; + + while (offset <= source.length - needle.length) { + const match = findSequence(source, needle, offset); + + if (match < 0) { + break; + } + + matches.push(match); + offset = match + needle.length; + } + + if (matches.length === 0) { + throw new TypeError('Failed to locate the generated multipart boundary in the request body'); + } + + const output = new Uint8Array( + source.length + matches.length * (replacement.length - needle.length) + ); + + let sourceOffset = 0; + let outputOffset = 0; + + for (const match of matches) { + output.set(source.subarray(sourceOffset, match), outputOffset); + outputOffset += match - sourceOffset; + output.set(replacement, outputOffset); + outputOffset += replacement.length; + sourceOffset = match + needle.length; + } + + output.set(source.subarray(sourceOffset), outputOffset); + + return output; +} + +/** Lazily encoded multipart body with a browser-compatible boundary. */ +export class MultipartBody { + readonly contentType: string; + + constructor( + private readonly formDataValue: FormData, + private readonly targetBoundary: string + ) { + this.contentType = `multipart/form-data; boundary=${targetBoundary}`; + } + + clone(): MultipartBody { + return new MultipartBody(cloneFormData(this.formDataValue), this.targetBoundary); + } + + withBoundary(boundary: string): MultipartBody { + validateMultipartBoundary(boundary); + + return new MultipartBody(cloneFormData(this.formDataValue), boundary); + } + + async arrayBuffer(): Promise { + const request = new globalThis.Request(FORM_DATA_PLACEHOLDER_URL, { + method: 'POST', + body: cloneFormData(this.formDataValue), + }); + + const contentType = request.headers.get('content-type') ?? ''; + const sourceBoundary = /boundary=(?:"([^"]+)"|([^;]+))/i + .exec(contentType) + ?.slice(1) + .find(Boolean); + + if (!sourceBoundary) { + throw new TypeError('Failed to determine the generated multipart boundary'); + } + + const bytes = new Uint8Array(await request.arrayBuffer()); + const rewritten = replaceBoundaryBytes(bytes, sourceBoundary, this.targetBoundary); + + return Uint8Array.from(rewritten).buffer; + } + + async formData(): Promise { + const request = new globalThis.Request(FORM_DATA_PLACEHOLDER_URL, { + method: 'POST', + body: cloneFormData(this.formDataValue), + }); + + return request.formData(); + } + + cloneFormData(): FormData { + return cloneFormData(this.formDataValue); + } + + get boundary(): string { + return this.targetBoundary; + } + + prepareNativeUpload(): NativeMultipartUpload { + const sources: Array<{ blob: Blob; handle: number }> = []; + const parts: NativeMultipartUpload['body']['parts'] = []; + + try { + for (const [rawName, value] of this.formDataValue.entries()) { + const name = escapeMultipartParameter(rawName); + + if (typeof value === 'string') { + parts.push({ + kind: 'text', + name, + value: normalizeLineEndings(value), + }); + + continue; + } + + const handle = nativeCreateUpload(); + const streamPart: NativeMultipartStreamPart = { + kind: 'stream', + name, + fileName: escapeMultipartParameter(value.name), + mimeType: value.type || 'application/octet-stream', + length: value.size, + uploadHandle: handle, + }; + + sources.push({ blob: value, handle }); + parts.push(streamPart); + } + } catch (error) { + for (const source of sources) { + nativeFinishUpload(source.handle); + } + + throw error; + } + + let started = false; + let cancelled = false; + const readers = new Set>(); + + const cancel = () => { + if (cancelled) { + return; + } + + cancelled = true; + + for (const reader of readers) { + void reader.cancel().catch(() => undefined); + } + + for (const source of sources) { + nativeFinishUpload(source.handle); + } + }; + + return { + body: { + boundary: this.targetBoundary, + parts, + }, + cancel, + async start(signal?: AbortSignal | null): Promise { + if (started) { + throw new TypeError('Multipart upload has already started'); + } + + started = true; + + for (const source of sources) { + if (cancelled || signal?.aborted) { + cancel(); + + return; + } + + let reader: ReadableStreamDefaultReader | undefined; + + try { + reader = source.blob.stream().getReader(); + readers.add(reader); + + while (true) { + if (cancelled || signal?.aborted) { + break; + } + + const result = await reader.read(); + + if (result.done) { + break; + } + + await nativeWriteUploadChunk(source.handle, result.value); + } + + nativeFinishUpload(source.handle); + } catch (error) { + if (cancelled) { + return; + } + + try { + await nativeFailUpload(source.handle, error); + } catch { + // The native request may already have closed its receiver. + } + + cancel(); + throw error; + } finally { + if (reader) { + readers.delete(reader); + + try { + reader.releaseLock(); + } catch { + // Cancellation may still be settling an outstanding read. + } + } + } + } + }, + }; + } +} + +export function createMultipartRequest(body: FormData, boundary?: string): MultipartBody { 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, - }); + const targetBoundary = boundary ?? generateWebKitBoundary(); + + validateMultipartBoundary(targetBoundary); + + return new MultipartBody(cloneFormData(body), targetBoundary); } export function toBodyBytes( diff --git a/src/http/body/form-data.ts b/src/http/body/form-data.ts index e6eb3a7..61c32f3 100644 --- a/src/http/body/form-data.ts +++ b/src/http/body/form-data.ts @@ -74,6 +74,7 @@ export async function parseResponseFormData( enumerable: false, writable: true, }); + throw wrapped; } } diff --git a/src/http/fetch.ts b/src/http/fetch.ts index c019324..88c5bd6 100644 --- a/src/http/fetch.ts +++ b/src/http/fetch.ts @@ -1,3 +1,4 @@ +import type { RedirectEntry, RequestInput, RetryDecisionContext, WreqInit } from '../types'; import { HTTPError, RequestError } from '../errors'; import { runAfterResponseHooks, @@ -8,7 +9,6 @@ import { runInitHooks, } from '../hooks'; import { normalizeMethod } from '../native/index'; -import type { RedirectEntry, RequestInput, RetryDecisionContext, WreqInit } from '../types'; import { loadCookiesIntoRequest, persistResponseCookies } from './pipeline/cookies'; import { dispatchNativeRequest, reportStats } from './pipeline/dispatch'; import { isResponseStatusAllowed, normalizeRequestError, throwIfAborted } from './pipeline/errors'; @@ -18,7 +18,7 @@ import { finalizeResponse, isRedirectResponse, resolveRedirectLocation, - rewriteRedirectMethodAndBody, + rewriteRedirectMethod, stripRedirectSensitiveHeaders, toRedirectEntry, } from './pipeline/redirects'; @@ -26,6 +26,15 @@ import { runRetryDelay, shouldRetryRequest } from './pipeline/retries'; /** Performs an HTTP request using the native transport pipeline. */ export async function fetch(input: RequestInput, init?: WreqInit) { + return fetchWithNativeClient(input, init); +} + +/** @internal Performs a request using a reusable native client owner. */ +export async function fetchWithNativeClient( + input: RequestInput, + init?: WreqInit, + clientId?: number +) { const merged = await mergeInputAndInit(input, init); const state = (merged.init.context ? { ...merged.init.context } : {}) as Record; @@ -63,7 +72,7 @@ export async function fetch(input: RequestInput, init?: WreqInit) { let response = shortCircuit ?? (await dispatchNativeRequest( - await buildNativeRequest(request, options), + await buildNativeRequest(request, options, clientId), startTime, options.signal )); @@ -133,17 +142,18 @@ export async function fetch(input: RequestInput, init?: WreqInit) { }); } - const rewritten = rewriteRedirectMethodAndBody( - normalizeMethod(request.method), - response.status, - (await request._cloneBodyBytes()) ?? undefined - ); - - const nextRequest = request._replace({ - url: nextUrl, - method: rewritten.method, - body: rewritten.body, - }); + const rewritten = rewriteRedirectMethod(normalizeMethod(request.method), response.status); + + const nextRequest = rewritten.bodyDropped + ? request._replace({ + url: nextUrl, + method: rewritten.method, + body: null, + }) + : request._replace({ + url: nextUrl, + method: rewritten.method, + }); stripRedirectSensitiveHeaders( nextRequest.headers, diff --git a/src/http/pipeline/cookies.ts b/src/http/pipeline/cookies.ts index 0f99fb7..89d6345 100644 --- a/src/http/pipeline/cookies.ts +++ b/src/http/pipeline/cookies.ts @@ -1,5 +1,5 @@ -import { Headers } from '../../headers'; import type { CookieJar } from '../../types'; +import { Headers } from '../../headers'; import { Request } from '../request'; import { Response } from '../response'; diff --git a/src/http/pipeline/dispatch.ts b/src/http/pipeline/dispatch.ts index 187f57b..4369632 100644 --- a/src/http/pipeline/dispatch.ts +++ b/src/http/pipeline/dispatch.ts @@ -1,6 +1,6 @@ +import type { NativeRequestOptions, RequestStats, WreqInit } from '../../types'; import { AbortError, RequestError, TimeoutError } from '../../errors'; import { nativeRequest } from '../../native/index'; -import type { NativeRequestOptions, RequestStats, WreqInit } from '../../types'; import { Response } from '../response'; export async function reportStats( diff --git a/src/http/pipeline/errors.ts b/src/http/pipeline/errors.ts index 38ff599..df5afa8 100644 --- a/src/http/pipeline/errors.ts +++ b/src/http/pipeline/errors.ts @@ -1,5 +1,5 @@ -import { AbortError, HTTPError, RequestError, TimeoutError } from '../../errors'; import type { ResolvedOptions } from '../../types'; +import { AbortError, HTTPError, RequestError, TimeoutError } from '../../errors'; import { Request } from '../request'; import { Response } from '../response'; diff --git a/src/http/pipeline/input.ts b/src/http/pipeline/input.ts index cbbcb31..bc073c0 100644 --- a/src/http/pipeline/input.ts +++ b/src/http/pipeline/input.ts @@ -1,6 +1,6 @@ +import type { RequestInput, WreqInit } from '../../types'; import { Buffer } from 'node:buffer'; import { RequestError } from '../../errors'; -import type { RequestInput, WreqInit } from '../../types'; import { Request } from '../request'; function isGlobalRequest(value: unknown): value is globalThis.Request { @@ -28,10 +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 - : ((await input._cloneBodyBytes()) ?? undefined), + body: init?.body !== undefined ? init.body : (input._cloneBodyInit() ?? undefined), + multipartBoundary: + init?.multipartBoundary ?? + (init?.body === undefined ? input._getMultipartBoundary() : undefined), } : { ...init }, }; diff --git a/src/http/pipeline/options.ts b/src/http/pipeline/options.ts index 39595d4..567bceb 100644 --- a/src/http/pipeline/options.ts +++ b/src/http/pipeline/options.ts @@ -1,3 +1,9 @@ +import type { + NativeRequestOptions, + ResolvedOptions, + ResolvedRetryOptions, + WreqInit, +} from '../../types'; import { Buffer } from 'node:buffer'; import { serializeEmulationOptions } from '../../config/emulation'; import { @@ -12,13 +18,8 @@ import { normalizeTlsIdentity, } from '../../config/tls'; import { Headers } from '../../headers'; -import { normalizeMethod, validateBrowserProfile } from '../../native/index'; -import type { - NativeRequestOptions, - ResolvedOptions, - ResolvedRetryOptions, - WreqInit, -} from '../../types'; +import { createNativeClientCacheKey } from '../../native/client-cache'; +import { normalizeBrowserEmulation, normalizeMethod } from '../../native/index'; import { Request } from '../request'; const DEFAULT_RETRY_METHODS = ['GET', 'HEAD'] as const; @@ -112,53 +113,83 @@ export function resolveOptions(init: WreqInit): ResolvedOptions { } export function createRequest(urlInput: string | URL, options: ResolvedOptions): Request { - validateBrowserProfile(options.browser); + normalizeBrowserEmulation(options.browser); return new Request(resolveUrl(urlInput, options.baseURL, options.query), { method: normalizeMethod(options.method), headers: options.headers, body: options.body, + multipartBoundary: options.multipartBoundary, signal: options.signal ?? undefined, }); } export async function buildNativeRequest( request: Request, - options: ResolvedOptions + options: ResolvedOptions, + clientId?: number ): Promise { const { proxy, disableSystemProxy } = normalizeProxyOptions(options.proxy); - const body = await request._getBodyBytesForDispatch(); const timeout = resolveNativeTimeout(options.timeout); const readTimeout = resolveNativeDuration('readTimeout', options.readTimeout); const connectTimeout = resolveNativeDuration('connectTimeout', options.connectTimeout); const localBind = normalizeLocalBindOptions(options); + const browser = normalizeBrowserEmulation(options.browser); + const dns = normalizeDnsOptions(options.dns); + const tlsIdentity = normalizeTlsIdentity(options.tlsIdentity); + const ca = normalizeCertificateAuthority(options.ca); + const tlsDebug = normalizeTlsDebug(options.tlsDebug); + const tlsDanger = normalizeTlsDanger(options.tlsDanger); if (options.http1Only && options.http2Only) { throw new TypeError('http1Only and http2Only cannot both be true'); } - return { - url: request.url, - method: normalizeMethod(request.method), - headers: request.headers.toTuples(), - origHeaders: request.headers.toOriginalNames(), - body: body ? Buffer.from(body) : undefined, - browser: options.browser, - emulationJson: serializeEmulationOptions(options), - proxy, - disableSystemProxy, - dns: normalizeDnsOptions(options.dns), - ...timeout, - ...readTimeout, - ...connectTimeout, - disableDefaultHeaders: options.disableDefaultHeaders, - compress: options.compress, - http1Only: options.http1Only, - http2Only: options.http2Only, - ...localBind, - tlsIdentity: normalizeTlsIdentity(options.tlsIdentity), - ca: normalizeCertificateAuthority(options.ca), - tlsDebug: normalizeTlsDebug(options.tlsDebug), - tlsDanger: normalizeTlsDanger(options.tlsDanger), - }; + const multipartUpload = request._prepareMultipartUpload(); + + try { + const body = multipartUpload ? undefined : await request._getBodyBytesForDispatch(); + const nativeOptions: NativeRequestOptions = { + url: request.url, + method: normalizeMethod(request.method), + headers: request.headers.toTuples(), + origHeaders: request.headers.toOriginalNames(), + body: body ? Buffer.from(body) : undefined, + multipart: multipartUpload?.body, + multipartUpload, + ...browser, + emulationJson: serializeEmulationOptions(options), + proxy, + disableSystemProxy, + dns, + ...timeout, + ...readTimeout, + ...connectTimeout, + poolIdleTimeout: options.poolIdleTimeout, + poolMaxIdlePerHost: options.poolMaxIdlePerHost, + poolMaxSize: options.poolMaxSize, + tlsSessionCacheCapacity: options.tlsSessionCacheCapacity, + disableDefaultHeaders: options.disableDefaultHeaders, + compress: options.compress, + http1Only: options.http1Only, + http2Only: options.http2Only, + ...localBind, + tlsIdentity, + ca, + tlsDebug, + tlsDanger, + connectionGroup: options.connectionGroup, + forbidConnectionReuse: options.forbidConnectionReuse, + }; + + if (clientId !== undefined) { + nativeOptions.clientId = clientId; + nativeOptions.clientCacheKey = createNativeClientCacheKey(nativeOptions); + } + + return nativeOptions; + } catch (error) { + multipartUpload?.cancel(error); + throw error; + } } diff --git a/src/http/pipeline/redirects.ts b/src/http/pipeline/redirects.ts index 321bdfb..3b0ea87 100644 --- a/src/http/pipeline/redirects.ts +++ b/src/http/pipeline/redirects.ts @@ -1,7 +1,7 @@ +import type { HttpMethod, RedirectEntry } from '../../types'; import { RequestError } from '../../errors'; import { Headers } from '../../headers'; import { normalizeMethod } from '../../native/index'; -import type { BodyInit, HttpMethod, RedirectEntry } from '../../types'; import { Response } from '../response'; const REDIRECT_STATUS_CODES = new Set([300, 301, 302, 303, 307, 308]); @@ -44,19 +44,16 @@ export function stripRedirectSensitiveHeaders( } } -export function rewriteRedirectMethodAndBody( +export function rewriteRedirectMethod( method: HttpMethod, - status: number, - body: BodyInit | null | undefined + status: number ): { method: HttpMethod; - body: BodyInit | null | undefined; bodyDropped: boolean; } { if (status === 303) { return { method: method === 'HEAD' ? 'HEAD' : 'GET', - body: undefined, bodyDropped: true, }; } @@ -64,14 +61,12 @@ export function rewriteRedirectMethodAndBody( if ((status === 301 || status === 302) && method === 'POST') { return { method: 'GET', - body: undefined, bodyDropped: true, }; } return { method, - body, bodyDropped: false, }; } diff --git a/src/http/pipeline/retries.ts b/src/http/pipeline/retries.ts index 17b0052..64517b8 100644 --- a/src/http/pipeline/retries.ts +++ b/src/http/pipeline/retries.ts @@ -1,5 +1,5 @@ -import { normalizeMethod } from '../../native/index'; import type { ResolvedRetryOptions, RetryDecisionContext } from '../../types'; +import { normalizeMethod } from '../../native/index'; import { inferErrorCode } from './errors'; async function sleep(delayMs: number): Promise { diff --git a/src/http/request.ts b/src/http/request.ts index 4291906..46fa262 100644 --- a/src/http/request.ts +++ b/src/http/request.ts @@ -1,12 +1,13 @@ +import type { BodyInit, HeadersInit, NativeMultipartUpload, WreqInit } from '../types'; import { Blob, Buffer } from 'node:buffer'; import { ReadableStream } from 'node:stream/web'; import { Headers } from '../headers'; -import type { BodyInit, HeadersInit, WreqInit } from '../types'; import { cloneBodyInit, cloneBytes, createMultipartRequest, isFormDataBody, + MultipartBody, toBodyBytes, } from './body/bytes'; @@ -21,7 +22,7 @@ export class Request { /** Abort signal associated with the request, if any. */ readonly signal: AbortSignal | null; #bodyBytes: Uint8Array | null; - #multipartBody: globalThis.Request | null; + #multipartBody: MultipartBody | null; #bodyUsed = false; #stream: ReadableStream | null = null; @@ -39,10 +40,15 @@ export class Request { this.#multipartBody = null; if (init.body !== undefined) { - this.#setBody(init.body); + this.#setBody(init.body, init.multipartBoundary); } else { this.#bodyBytes = cloneBytes(input.#bodyBytes); this.#multipartBody = input.#multipartBody?.clone() ?? null; + + if (init.multipartBoundary && this.#multipartBody) { + this.#multipartBody = this.#multipartBody.withBoundary(init.multipartBoundary); + this.headers.set('content-type', this.#multipartBody.contentType); + } } return; @@ -54,7 +60,7 @@ export class Request { this.signal = init.signal ?? null; this.#bodyBytes = null; this.#multipartBody = null; - this.#setBody(init.body); + this.#setBody(init.body, init.multipartBoundary); } /** Returns the request body as a readable byte stream. */ @@ -158,6 +164,25 @@ export class Request { return new Uint8Array(await this.#multipartBody.clone().arrayBuffer()); } + /** Internal helper that clones the source body without forcing multipart encoding. */ + _cloneBodyInit(): BodyInit | null { + if (this.#bodyBytes !== null) { + return cloneBytes(this.#bodyBytes); + } + + return this.#multipartBody?.cloneFormData() ?? null; + } + + /** Internal helper that returns the explicit multipart boundary, when applicable. */ + _getMultipartBoundary(): string | undefined { + return this.#multipartBody?.boundary; + } + + /** Internal helper that prepares a native streaming multipart upload. */ + _prepareMultipartUpload(): NativeMultipartUpload | undefined { + return this.#multipartBody?.prepareNativeUpload(); + } + /** Internal helper that prepares body bytes for native dispatch. */ async _getBodyBytesForDispatch(): Promise { return (await this._cloneBodyBytes()) ?? undefined; @@ -193,7 +218,7 @@ export class Request { return next; } - #setBody(body: BodyInit | null | undefined): void { + #setBody(body: BodyInit | null | undefined, multipartBoundary?: string): void { const nextBody = cloneBodyInit(body); this.#stream = null; @@ -206,15 +231,12 @@ export class Request { } if (isFormDataBody(nextBody)) { - const multipartBody = createMultipartRequest(nextBody); - const contentType = multipartBody.headers.get('content-type'); + const multipartBody = createMultipartRequest(nextBody, multipartBoundary); this.#bodyBytes = null; this.#multipartBody = multipartBody; - if (contentType) { - this.headers.set('content-type', contentType); - } + this.headers.set('content-type', multipartBody.contentType); return; } diff --git a/src/http/response-meta.ts b/src/http/response-meta.ts index e225270..a22c3a2 100644 --- a/src/http/response-meta.ts +++ b/src/http/response-meta.ts @@ -1,6 +1,6 @@ -import { Readable } from 'node:stream'; import type { RequestTimings, RedirectEntry, TlsPeerInfo, WreqResponseMeta } from '../types'; import type { Response } from './response'; +import { Readable } from 'node:stream'; /** Implementation backing the `response.wreq` metadata surface. */ export class ResponseMeta implements WreqResponseMeta { @@ -59,4 +59,9 @@ export class ResponseMeta implements WreqResponseMeta { return body ? Readable.fromWeb(body) : Readable.from([]); } + + /** Prevents the current native connection from being returned to the pool. */ + forbidConnectionReuse(): boolean { + return this.response._forbidConnectionReuse(); + } } diff --git a/src/http/response.ts b/src/http/response.ts index 64db1cf..119e8d4 100644 --- a/src/http/response.ts +++ b/src/http/response.ts @@ -1,10 +1,3 @@ -import { Blob, Buffer } from 'node:buffer'; -import { STATUS_CODES } from 'node:http'; -import { ReadableStream } from 'node:stream/web'; -import { TextDecoder } from 'node:util'; -import { RequestError, TimeoutError } from '../errors'; -import { Headers } from '../headers'; -import { nativeCancelBody, nativeReadBodyChunk } from '../native/index'; import type { BodyInit, HeadersInit, @@ -14,6 +7,13 @@ import type { TlsPeerInfo, WreqResponseMeta, } from '../types'; +import { Blob, Buffer } from 'node:buffer'; +import { STATUS_CODES } from 'node:http'; +import { ReadableStream } from 'node:stream/web'; +import { TextDecoder } from 'node:util'; +import { RequestError, TimeoutError } from '../errors'; +import { Headers } from '../headers'; +import { nativeCancelBody, nativeForbidBodyRecycle, nativeReadBodyChunk } from '../native/index'; import { cloneBytes, toBodyBytes } from './body/bytes'; import { parseResponseFormData } from './body/form-data'; import { ResponseMeta } from './response-meta'; @@ -188,6 +188,15 @@ export class Response { return this; } + /** Internal helper used by response metadata to poison the native connection. */ + _forbidConnectionReuse(): boolean { + if (this.#bodyHandle === null || this.#bodyUsed) { + return false; + } + + return nativeForbidBodyRecycle(this.#bodyHandle); + } + /** Returns the response body as a readable byte stream. */ get body(): ReadableStream | null { return this.#ensureStream(); @@ -249,6 +258,7 @@ export class Response { this.#streamSource = left; this.#stream = null; cloned.#streamSource = right; + if (previousStream) { this.#orphanedStreamReaders.push(previousStream.getReader()); } @@ -358,6 +368,7 @@ export class Response { pull: async (controller) => { this.#bodyUsed = true; sourceReader ??= source.getReader(); + const result = await sourceReader.read(); if (result.done) { @@ -371,6 +382,7 @@ export class Response { }, cancel: async () => { this.#bodyUsed = true; + if (sourceReader) { await sourceReader.cancel(); } else { @@ -395,9 +407,11 @@ export class Response { if (stream) { this.#bodyUsed = true; + const reader = stream.getReader(); this.#orphanedStreamReaders.push(reader); + const chunks: Uint8Array[] = []; while (true) { diff --git a/src/index.ts b/src/index.ts index ce0baff..37596a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,3 @@ -import { createClient } from './client'; -import { BROWSER_PROFILES } from './config/generated/browser-profiles'; -import { AbortError, HTTPError, RequestError, TimeoutError, WebSocketError } from './errors'; -import { Headers } from './headers'; -import { fetch } from './http/fetch'; -import { Request } from './http/request'; -import { Response } from './http/response'; -import { getProfiles } from './native/index'; import type { AfterResponseContext, AlpnProtocol, @@ -15,6 +7,10 @@ import type { BeforeRetryContext, BeforeRequestContext, BodyInit, + BrowserEmulation, + BrowserEmulationMode, + BrowserEmulationOptions, + BrowserPlatform, BrowserProfile, CertificateAuthority, Client, @@ -51,12 +47,21 @@ import type { RetryDecisionContext, RetryOptions, TlsIdentity, + TlsKeyShare, TlsOptions, TlsVersion, WebSocketBinaryType, WebSocketInit, WreqInit, } from './types'; +import { createClient } from './client'; +import { BROWSER_PROFILES } from './config/generated/browser-profiles'; +import { AbortError, HTTPError, RequestError, TimeoutError, WebSocketError } from './errors'; +import { Headers } from './headers'; +import { fetch } from './http/fetch'; +import { Request } from './http/request'; +import { Response } from './http/response'; +import { getProfiles } from './native/index'; import { CloseEvent, WebSocket, websocket } from './websocket'; export { @@ -87,6 +92,10 @@ export type { BeforeRetryContext, BeforeRequestContext, BodyInit, + BrowserEmulation, + BrowserEmulationMode, + BrowserEmulationOptions, + BrowserPlatform, BrowserProfile, CertificateAuthority, Client, @@ -123,6 +132,7 @@ export type { RetryDecisionContext, RetryOptions, TlsIdentity, + TlsKeyShare, TlsOptions, TlsVersion, WebSocketBinaryType, diff --git a/src/native/binding.ts b/src/native/binding.ts index d9ec7dc..a35ef88 100644 --- a/src/native/binding.ts +++ b/src/native/binding.ts @@ -1,5 +1,3 @@ -import { execSync } from 'node:child_process'; -import { resolve } from 'node:path'; import type { NativeRequestOptions, NativeResponse, @@ -7,6 +5,8 @@ import type { NativeWebSocketConnection, NativeWebSocketReadResult, } from '../types'; +import { execSync } from 'node:child_process'; +import { resolve } from 'node:path'; export type NativeBinding = { request: (options: NativeRequestOptions) => { @@ -14,6 +14,11 @@ export type NativeBinding = { promise: Promise; }; cancelRequest: (handle: number) => boolean; + createUpload: () => number; + writeUploadChunk: (handle: number, chunk: Buffer) => Promise; + failUpload: (handle: number, message: string) => Promise; + finishUpload: (handle: number) => boolean; + releaseClient: (clientId: number) => boolean; websocketConnect: (options: NativeWebSocketConnectOptions) => Promise; websocketRead: (handle: number) => Promise; websocketSendText: (handle: number, text: string) => Promise; @@ -27,6 +32,7 @@ export type NativeBinding = { done: boolean; }>; cancelBody: (handle: number) => boolean; + forbidBodyRecycle: (handle: number) => boolean; getProfiles: () => string[]; }; diff --git a/src/native/client-cache.ts b/src/native/client-cache.ts new file mode 100644 index 0000000..d7c83fb --- /dev/null +++ b/src/native/client-cache.ts @@ -0,0 +1,33 @@ +import type { NativeRequestOptions, NativeWebSocketConnectOptions } from '../types'; + +type NativeClientOptions = NativeRequestOptions | NativeWebSocketConnectOptions; + +/** Builds a stable-enough key for the native client-builder inputs we control. */ +export function createNativeClientCacheKey(options: NativeClientOptions): string { + return JSON.stringify({ + browser: options.browser, + browserMode: options.browserMode, + browserPlatform: options.browserPlatform, + browserHttp2: options.browserHttp2, + browserHeaders: options.browserHeaders, + emulationJson: options.emulationJson, + proxy: options.proxy, + disableSystemProxy: options.disableSystemProxy, + dns: options.dns, + timeout: 'protocols' in options ? options.timeout : undefined, + connectTimeout: 'connectTimeout' in options ? options.connectTimeout : undefined, + poolIdleTimeout: options.poolIdleTimeout, + poolMaxIdlePerHost: options.poolMaxIdlePerHost, + poolMaxSize: options.poolMaxSize, + tlsSessionCacheCapacity: options.tlsSessionCacheCapacity, + http1Only: 'http1Only' in options ? options.http1Only : undefined, + http2Only: 'http2Only' in options ? options.http2Only : undefined, + localAddress: options.localAddress, + localAddresses: options.localAddresses, + interface: options.interface, + tlsIdentity: options.tlsIdentity, + ca: options.ca, + tlsDebug: options.tlsDebug, + tlsDanger: options.tlsDanger, + }); +} diff --git a/src/native/index.ts b/src/native/index.ts index dbbc954..c9cd3fb 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -1,6 +1,17 @@ export { normalizeMethod } from './methods'; -export { getProfiles, validateBrowserProfile } from './profiles'; -export { nativeCancelBody, nativeReadBodyChunk, nativeRequest } from './request'; +export { getProfiles, normalizeBrowserEmulation, validateBrowserProfile } from './profiles'; +export { + nativeCancelBody, + nativeForbidBodyRecycle, + nativeCreateUpload, + nativeFailUpload, + nativeFinishUpload, + nativeReadBodyChunk, + nativeReleaseClient, + nativeRequest, + nativeWriteUploadChunk, +} from './request'; + export { nativeWebSocketClose, nativeWebSocketConnect, diff --git a/src/native/profiles.ts b/src/native/profiles.ts index a6219de..14db2cf 100644 --- a/src/native/profiles.ts +++ b/src/native/profiles.ts @@ -1,4 +1,9 @@ -import type { BrowserProfile } from '../types'; +import type { + BrowserEmulation, + BrowserEmulationMode, + BrowserPlatform, + BrowserProfile, +} from '../types'; import { getBinding } from './binding'; let cachedProfiles: BrowserProfile[] | undefined; @@ -10,12 +15,51 @@ export function getProfiles(): BrowserProfile[] { return cachedProfiles; } -export function validateBrowserProfile(browser?: BrowserProfile): void { +export interface NormalizedBrowserEmulation { + browser?: BrowserProfile; + browserMode?: BrowserEmulationMode; + browserPlatform?: BrowserPlatform; + browserHttp2?: boolean; + browserHeaders?: boolean; +} + +/** Validates and normalizes the public browser emulation selector. */ +export function normalizeBrowserEmulation(browser?: BrowserEmulation): NormalizedBrowserEmulation { if (!browser) { - return; + return {}; } - if (!getProfiles().includes(browser)) { - throw new Error(`Invalid browser profile: ${browser}`); + if (typeof browser === 'string') { + if (!getProfiles().includes(browser)) { + throw new Error(`Invalid browser profile: ${browser}`); + } + + return { browser, browserMode: 'fixed' }; } + + const mode = browser.mode ?? 'fixed'; + + if (mode !== 'fixed' && mode !== 'random' && mode !== 'weighted-random') { + throw new Error(`Invalid browser emulation mode: ${String(mode)}`); + } + + if (mode !== 'fixed' && (browser.profile || browser.platform)) { + throw new Error(`browser.profile and browser.platform cannot be used with mode ${mode}`); + } + + if (browser.profile && !getProfiles().includes(browser.profile)) { + throw new Error(`Invalid browser profile: ${browser.profile}`); + } + + return { + browser: browser.profile, + browserMode: mode, + browserPlatform: browser.platform, + browserHttp2: browser.http2, + browserHeaders: browser.headers, + }; +} + +export function validateBrowserProfile(browser?: BrowserEmulation): void { + normalizeBrowserEmulation(browser); } diff --git a/src/native/request.ts b/src/native/request.ts index 36e2078..f0c0372 100644 --- a/src/native/request.ts +++ b/src/native/request.ts @@ -1,5 +1,6 @@ -import { AbortError } from '../errors'; import type { NativeRequestOptions, NativeResponse } from '../types'; +import { Buffer } from 'node:buffer'; +import { AbortError } from '../errors'; import { getBinding } from './binding'; export async function nativeRequest( @@ -7,13 +8,32 @@ export async function nativeRequest( signal?: AbortSignal | null ): Promise { if (signal?.aborted) { + options.multipartUpload?.cancel(signal.reason); throw new AbortError(undefined, { cause: signal.reason }); } - const task = getBinding().request(options); + const { multipartUpload, ...nativeOptions } = options; + let task: ReturnType['request']>; + + try { + task = getBinding().request(nativeOptions); + } catch (error) { + multipartUpload?.cancel(error); + throw error; + } + + if (multipartUpload) { + // Upload errors are forwarded through the native body stream so the request + // retains the original failure instead of being replaced with a generic abort. + void multipartUpload.start(signal).catch(() => undefined); + } if (!signal) { - return task.promise; + try { + return await task.promise; + } finally { + multipartUpload?.cancel(); + } } return new Promise((resolve, reject) => { @@ -30,6 +50,7 @@ export async function nativeRequest( settled = true; cleanup(); + multipartUpload?.cancel(signal.reason); getBinding().cancelRequest(task.handle); reject(new AbortError(undefined, { cause: signal.reason })); }; @@ -44,6 +65,7 @@ export async function nativeRequest( settled = true; cleanup(); + multipartUpload?.cancel(); resolve(response); }, (error) => { @@ -53,12 +75,34 @@ export async function nativeRequest( settled = true; cleanup(); + multipartUpload?.cancel(error); reject(error); } ); }); } +export function nativeCreateUpload(): number { + return getBinding().createUpload(); +} + +export async function nativeWriteUploadChunk(handle: number, chunk: Uint8Array): Promise { + await getBinding().writeUploadChunk( + handle, + Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) + ); +} + +export async function nativeFailUpload(handle: number, reason: unknown): Promise { + const message = reason instanceof Error ? reason.message : String(reason ?? 'Upload failed'); + + await getBinding().failUpload(handle, message); +} + +export function nativeFinishUpload(handle: number): boolean { + return getBinding().finishUpload(handle); +} + export async function nativeReadBodyChunk( handle: number, size?: number @@ -72,3 +116,11 @@ export async function nativeReadBodyChunk( export function nativeCancelBody(handle: number): boolean { return getBinding().cancelBody(handle); } + +export function nativeReleaseClient(clientId: number): boolean { + return getBinding().releaseClient(clientId); +} + +export function nativeForbidBodyRecycle(handle: number): boolean { + return getBinding().forbidBodyRecycle(handle); +} diff --git a/src/native/websocket.ts b/src/native/websocket.ts index c769c54..975bc32 100644 --- a/src/native/websocket.ts +++ b/src/native/websocket.ts @@ -1,9 +1,9 @@ -import { Buffer } from 'node:buffer'; import type { NativeWebSocketConnectOptions, NativeWebSocketConnection, NativeWebSocketReadResult, } from '../types'; +import { Buffer } from 'node:buffer'; import { getBinding } from './binding'; export async function nativeWebSocketConnect( diff --git a/src/platform/ws.d.ts b/src/platform/ws.d.ts index 9ebbfbe..ed29813 100644 --- a/src/platform/ws.d.ts +++ b/src/platform/ws.d.ts @@ -1,7 +1,7 @@ declare module 'ws' { - import { EventEmitter } from 'node:events'; import type { IncomingMessage } from 'node:http'; import type { Duplex } from 'node:stream'; + import { EventEmitter } from 'node:events'; export class WebSocket extends EventEmitter { protocol: string; diff --git a/src/test/cookies-redirects.spec.ts b/src/test/cookies-redirects.spec.ts index 64fabc4..a7bbf2e 100644 --- a/src/test/cookies-redirects.spec.ts +++ b/src/test/cookies-redirects.spec.ts @@ -126,6 +126,7 @@ describe('cookies and redirects', () => { body.cookie.includes('redirect_session=1'), 'intermediate set-cookie should affect the next redirect hop' ); + assert.strictEqual( body.hookHeader, 'active', @@ -154,11 +155,13 @@ describe('cookies and redirects', () => { 302, 'manual redirect mode should return the redirect response' ); + assert.strictEqual( response.headers.get('location'), '/redirect/final', 'manual redirect mode should expose Location' ); + assert.strictEqual( response.redirected, false, diff --git a/src/test/helpers/local-server.ts b/src/test/helpers/local-server.ts index 04c841e..6cd30cc 100644 --- a/src/test/helpers/local-server.ts +++ b/src/test/helpers/local-server.ts @@ -1,6 +1,6 @@ +import { WebSocketServer, type WebSocket as WsPeer } from 'ws'; import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; 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'); @@ -15,49 +15,52 @@ export function onceEvent(target: EventTarget, type: string): P }); } -export function setupLocalTestServer() { - let localBaseUrl = ''; - let localServer: Server | undefined; - let wsServer: WebSocketServer | undefined; - const retryAttempts = new Map(); - - function readCookieHeader(request: IncomingMessage): string { - const cookie = request.headers.cookie; - - if (Array.isArray(cookie)) { - return cookie.join('; '); - } +function readCookieHeader(request: IncomingMessage): string { + const cookie = request.headers.cookie; - return cookie ?? ''; + if (Array.isArray(cookie)) { + return cookie.join('; '); } - function sendJson( - response: ServerResponse, - status: number, - body: unknown, - headers?: Record - ) { - response.writeHead(status, { - 'content-type': 'application/json', - ...headers, - }); - response.end(JSON.stringify(body)); - } + return cookie ?? ''; +} - function readQuery(url: URL): Record { - return Object.fromEntries(url.searchParams.entries()); - } +function sendJson( + response: ServerResponse, + status: number, + body: unknown, + headers?: Record +) { + response.writeHead(status, { + 'content-type': 'application/json', + ...headers, + }); - async function readRequestBody(request: IncomingMessage): Promise { - const chunks: Buffer[] = []; + response.end(JSON.stringify(body)); +} + +function readQuery(url: URL): Record { + return Object.fromEntries(url.searchParams.entries()); +} - for await (const chunk of request) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } +async function readRequestBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; - return Buffer.concat(chunks); + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } + return Buffer.concat(chunks); +} + +export function setupLocalTestServer() { + let localBaseUrl = ''; + let localServer: Server | undefined; + let wsServer: WebSocketServer | undefined; + const retryAttempts = new Map(); + const connectionIds = new WeakMap(); + let nextConnectionId = 1; + before(async () => { wsServer = new WebSocketServer({ noServer: true, @@ -100,6 +103,20 @@ export function setupLocalTestServer() { void (async () => { const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + if (url.pathname === '/connection/id') { + let connectionId = connectionIds.get(request.socket); + + if (connectionId === undefined) { + connectionId = nextConnectionId; + nextConnectionId += 1; + connectionIds.set(request.socket, connectionId); + } + + sendJson(response, 200, { connectionId }); + + return; + } + if (url.pathname === '/retry') { const key = url.searchParams.get('key') ?? 'default'; const failCount = Number(url.searchParams.get('failCount') ?? '0'); @@ -118,6 +135,30 @@ export function setupLocalTestServer() { return; } + if (url.pathname === '/retry/body') { + const key = url.searchParams.get('key') ?? 'default-body'; + const failCount = Number(url.searchParams.get('failCount') ?? '0'); + const count = (retryAttempts.get(key) ?? 0) + 1; + const body = await readRequestBody(request); + + retryAttempts.set(key, count); + + if (count <= failCount) { + sendJson(response, 503, { attempt: count }); + + return; + } + + sendJson(response, 200, { + attempt: count, + body: body.toString('utf8'), + bodyBase64: body.toString('base64'), + headers: request.headers, + }); + + return; + } + if (url.pathname === '/retry/timeout') { const key = url.searchParams.get('key') ?? 'default-timeout'; const failCount = Number(url.searchParams.get('failCount') ?? '0'); @@ -158,6 +199,7 @@ export function setupLocalTestServer() { response.writeHead(200, { 'content-type': 'application/json', }); + response.end(JSON.stringify({ delayedConnection: true })); }, delayMs); @@ -296,6 +338,7 @@ export function setupLocalTestServer() { 'content-type': 'text/plain; charset=windows-1251', 'content-length': String(WINDOWS_1251_BODY.length), }); + response.end(WINDOWS_1251_BODY); return; @@ -307,6 +350,7 @@ export function setupLocalTestServer() { 'content-encoding': 'zstd', 'content-length': String(ZSTD_RESPONSE_BODY.length), }); + response.end(ZSTD_RESPONSE_BODY); return; @@ -325,6 +369,7 @@ export function setupLocalTestServer() { location: '/redirect/final', 'set-cookie': 'redirect_session=1; Path=/', }); + response.end(); return; @@ -334,6 +379,19 @@ export function setupLocalTestServer() { response.writeHead(302, { location: '/redirect/final', }); + + response.end(); + + return; + } + + if (url.pathname === '/redirect/preserve-body') { + await readRequestBody(request); + + response.writeHead(307, { + location: '/body/echo', + }); + response.end(); return; @@ -346,6 +404,7 @@ export function setupLocalTestServer() { response.writeHead(302, { location: `/redirect/chain?count=${count - 1}`, }); + response.end(); return; @@ -354,6 +413,7 @@ export function setupLocalTestServer() { response.writeHead(302, { location: '/redirect/final', }); + response.end(); return; @@ -363,6 +423,7 @@ export function setupLocalTestServer() { response.writeHead(302, { location: '/redirect/loop-b', }); + response.end(); return; @@ -372,6 +433,7 @@ export function setupLocalTestServer() { response.writeHead(302, { location: '/redirect/loop-a', }); + response.end(); return; @@ -392,6 +454,7 @@ export function setupLocalTestServer() { response.writeHead(500, { 'content-type': 'application/json', }); + response.end( JSON.stringify({ error: error instanceof Error ? error.message : String(error), diff --git a/src/test/helpers/mtls-server.ts b/src/test/helpers/mtls-server.ts index 2a7e129..89b9058 100644 --- a/src/test/helpers/mtls-server.ts +++ b/src/test/helpers/mtls-server.ts @@ -1,13 +1,14 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { TLSSocket } from 'node:tls'; 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)); } diff --git a/src/test/helpers/proxy-server.ts b/src/test/helpers/proxy-server.ts index cdee512..fb51a76 100644 --- a/src/test/helpers/proxy-server.ts +++ b/src/test/helpers/proxy-server.ts @@ -28,6 +28,7 @@ export function setupProxyTestServer() { response.writeHead(502, { 'content-type': 'application/json', }); + response.end(JSON.stringify({ error: error.message })); }); }); diff --git a/src/test/hooks-retries.spec.ts b/src/test/hooks-retries.spec.ts index effc5ac..d4a79c2 100644 --- a/src/test/hooks-retries.spec.ts +++ b/src/test/hooks-retries.spec.ts @@ -80,6 +80,7 @@ describe('hooks and retries', () => { }); assert.strictEqual(response.status, 299, 'afterResponse should replace the response'); + const body = await response.json<{ replaced: boolean }>(); assert.strictEqual(body.replaced, true, 'Replaced response body should be returned'); @@ -111,6 +112,7 @@ describe('hooks and retries', () => { test('should run beforeRetry hooks and retry retriable responses', async () => { retryAttempts.set('status-retry', 0); + const hookAttempts: number[] = []; const response = await fetch(`${getBaseUrl()}/retry?key=status-retry&failCount=2`, { retry: { diff --git a/src/test/http-client.spec.ts b/src/test/http-client.spec.ts index a38890b..0ef638c 100644 --- a/src/test/http-client.spec.ts +++ b/src/test/http-client.spec.ts @@ -11,12 +11,16 @@ describe('http client', () => { assert.ok(Array.isArray(profiles), 'Profiles should be an array'); assert.ok(profiles.length > 0, 'Should have at least one profile'); - assert.ok( - profiles.includes('chrome_137') || - profiles.includes('firefox_139') || - profiles.includes('safari_18'), - 'Should include standard browser profiles' - ); + + for (const profile of [ + 'chrome_149', + 'edge_148', + 'firefox_151', + 'opera_131', + 'safari_26_4', + ] as const) { + assert.ok(profiles.includes(profile), `Should include upstream profile ${profile}`); + } }); test('should make a simple GET request', async () => { @@ -35,11 +39,11 @@ describe('http client', () => { test('should work with different browser profiles', async () => { const testUrl = `${getBaseUrl()}/user-agent`; - const browsers = ['chrome_137', 'firefox_139', 'safari_18']; + const browsers = ['chrome_149', 'firefox_151', 'safari_26_4'] as const; for (const browser of browsers) { const response = await fetch(testUrl, { - browser: browser as any, + browser, timeout: 30000, }); @@ -51,6 +55,40 @@ describe('http client', () => { } }); + test('should support fixed platform and automatic emulation selection', async () => { + for (const browser of [ + { profile: 'chrome_149', platform: 'windows' }, + { mode: 'random' }, + { mode: 'weighted-random' }, + ] as const) { + const response = await fetch(`${getBaseUrl()}/headers/raw`, { browser }); + const data = await response.json<{ headers: Record }>(); + + assert.ok(data.headers['user-agent']); + } + + await assert.rejects( + fetch(`${getBaseUrl()}/headers/raw`, { + browser: { mode: 'weighted-random', profile: 'chrome_149' }, + }), + /cannot be used with mode weighted-random/ + ); + + const withoutProfileHeaders = await fetch(`${getBaseUrl()}/headers/raw`, { + browser: { + profile: 'chrome_149', + http2: false, + headers: false, + }, + }); + + const withoutProfileHeadersBody = await withoutProfileHeaders.json<{ + headers: Record; + }>(); + + assert.strictEqual(withoutProfileHeadersBody.headers['user-agent'], undefined); + }); + test('should handle timeout errors', async () => { await assert.rejects( async () => { @@ -105,6 +143,7 @@ describe('http client', () => { (error: unknown) => { assert.ok(error instanceof Error); assert.strictEqual(error.name, 'RequestError'); + const cause = (error as { cause?: unknown }).cause; assert.ok( @@ -126,6 +165,7 @@ describe('http client', () => { (error: unknown) => { assert.ok(error instanceof Error); assert.strictEqual(error.name, 'RequestError'); + const cause = (error as { cause?: unknown }).cause; assert.ok(cause instanceof TypeError); @@ -148,6 +188,7 @@ describe('http client', () => { const customResponse = await fetch(`${getBaseUrl()}/body/echo`, { method: 'PROPFIND', }); + const customBody = await customResponse.json<{ method: string }>(); assert.strictEqual(customBody.method, 'PROPFIND'); @@ -170,8 +211,58 @@ describe('http client', () => { (await patchResponse.json<{ method: string; body: string }>()).body, 'patch-body' ); + assert.strictEqual((await deleteResponse.json<{ method: string }>()).method, 'DELETE'); assert.strictEqual(headResponse.status, 200); + client.close(); + }); + + test('should reuse and partition native pooled connections', async () => { + const client = createClient({ baseURL: getBaseUrl(), http1Only: true }); + + const readConnectionId = async (init?: { + connectionGroup?: string; + forbidConnectionReuse?: boolean; + }) => { + const response = await client.get('/connection/id', init); + + return (await response.json<{ connectionId: number }>()).connectionId; + }; + + const first = await readConnectionId({ connectionGroup: 'primary' }); + const reused = await readConnectionId({ connectionGroup: 'primary' }); + const isolated = await readConnectionId({ connectionGroup: 'isolated' }); + + assert.strictEqual(reused, first); + assert.notStrictEqual(isolated, first); + + const poisoned = await readConnectionId({ + connectionGroup: 'primary', + forbidConnectionReuse: true, + }); + + const replacement = await readConnectionId({ connectionGroup: 'primary' }); + + assert.strictEqual(poisoned, first); + assert.notStrictEqual(replacement, first); + + const dynamicResponse = await client.get('/connection/id', { + connectionGroup: 'primary', + }); + + assert.strictEqual(dynamicResponse.wreq.forbidConnectionReuse(), true); + + const dynamicallyPoisoned = (await dynamicResponse.json<{ connectionId: number }>()) + .connectionId; + + const dynamicReplacement = await readConnectionId({ connectionGroup: 'primary' }); + + assert.strictEqual(dynamicallyPoisoned, replacement); + assert.notStrictEqual(dynamicReplacement, replacement); + + client.close(); + + await assert.rejects(client.get('/connection/id'), /Client is closed/); }); test('should support http1Only and reject conflicting protocol forcing', async () => { @@ -288,6 +379,7 @@ describe('http client', () => { 'content-type': 'application/json', }, }); + const body = await response.json<{ method: string; body: string }>(); assert.strictEqual(body.method, 'POST'); @@ -374,6 +466,8 @@ describe('http client', () => { browser: 'chrome_137', tlsOptions: { greaseEnabled: true, + keyShares: ['X25519_MLKEM768', 'X25519', 'P256'], + certificateCompressionAlgorithms: ['brotli', 'zlib', 'zstd'], }, http1Options: { writev: true, @@ -385,6 +479,27 @@ describe('http client', () => { }); assert.strictEqual(response.status, 200); + + await assert.rejects( + fetch(`${getBaseUrl()}/headers/raw`, { + tlsOptions: { + keyShares: ['X25519'], + keySharesLimit: 1, + }, + }), + /keyShares and keySharesLimit cannot both be set/ + ); + }); + + test('should reject HTTP/2 experimental settings removed by upstream', async () => { + await assert.rejects( + fetch(`${getBaseUrl()}/headers/raw`, { + http2Options: { + experimentalSettings: [{ id: 10, value: 1 }], + }, + }), + /wreq 6\.0\.0-rc\.29 no longer exposes custom HTTP\/2 settings/ + ); }); test('should support native-like Request instances', async () => { diff --git a/src/test/mtls.spec.ts b/src/test/mtls.spec.ts index 0bfdde9..ddd66d2 100644 --- a/src/test/mtls.spec.ts +++ b/src/test/mtls.spec.ts @@ -87,6 +87,7 @@ test('should expose peer certificates in response metadata when requested', asyn Buffer.isBuffer(response.wreq.tls?.peerCertificate), 'leaf certificate should be returned as a Buffer' ); + assert.ok( (response.wreq.tls?.peerCertificateChain?.length ?? 0) >= 1, 'certificate chain should include at least the leaf certificate' diff --git a/src/test/response.spec.ts b/src/test/response.spec.ts index 9cccc29..4db469a 100644 --- a/src/test/response.spec.ts +++ b/src/test/response.spec.ts @@ -22,10 +22,10 @@ describe('response behavior', () => { assert.ok(bodyStream, 'body should expose a stream'); assert.strictEqual(response.bodyUsed, false, 'accessing body should not mark it used'); - const reader = bodyStream?.getReader(); + const reader = bodyStream.getReader(); const chunks: Uint8Array[] = []; - while (reader) { + while (true) { const result = await reader.read(); if (result.done) { @@ -73,6 +73,7 @@ describe('response behavior', () => { initialBody, 'clone should replace the original body branch' ); + assert.throws( () => initialBody?.getReader(), (error: unknown) => error instanceof TypeError && error.message.includes('locked'), @@ -152,10 +153,10 @@ describe('response behavior', () => { 'accessing the fetched body should not mark it used' ); - const reader = stream?.getReader(); + const reader = stream.getReader(); const chunks: Uint8Array[] = []; - while (reader) { + while (true) { const result = await reader.read(); if (result.done) { @@ -167,6 +168,7 @@ describe('response behavior', () => { true, 'reading the fetched body stream should mark it used' ); + chunks.push(result.value); } @@ -188,11 +190,13 @@ describe('response behavior', () => { left.includes('"cookie":""'), 'the tee payload should match the fetched response body' ); + assert.strictEqual( response.bodyUsed, true, 'original response should be marked used after reading' ); + assert.strictEqual( cloned.bodyUsed, true, diff --git a/src/test/transport-features.spec.ts b/src/test/transport-features.spec.ts index 6e6eb39..d214523 100644 --- a/src/test/transport-features.spec.ts +++ b/src/test/transport-features.spec.ts @@ -23,23 +23,221 @@ describe('transport features', () => { method: 'POST', body: formData, }); - const body = await response.json<{ body: string; headers: Record }>(); + + const body = await response.json<{ + body: string; + bodyBase64: string; + headers: Record; + }>(); assert.match( body.headers['content-type'], - /^multipart\/form-data; boundary=/, - 'multipart bodies should set a valid content-type boundary' + /^multipart\/form-data; boundary=----WebKitFormBoundary[0-9A-Za-z]{16}$/, + 'multipart bodies should use a WebKit-style 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' ); + + assert.strictEqual( + Number(body.headers['content-length']), + Buffer.from(body.bodyBase64, 'base64').byteLength, + 'native multipart should retain an exact content length while streaming files' + ); + }); + + test('should stream FormData files without calling Blob.arrayBuffer()', async () => { + const originalArrayBuffer = Blob.prototype.arrayBuffer; + const formData = new FormData(); + + formData.append( + 'upload', + new File([Buffer.alloc(1024 * 1024, 0x61)], 'streamed.bin', { + type: 'application/octet-stream', + }) + ); + + Blob.prototype.arrayBuffer = async () => { + throw new Error('Blob.arrayBuffer() must not be used by multipart dispatch'); + }; + + try { + const response = await fetch(`${getBaseUrl()}/body/echo`, { + method: 'POST', + body: formData, + }); + + const body = await response.json<{ bodyBase64: string }>(); + + assert.ok(body.bodyBase64.length > 1024 * 1024, 'the complete file should reach the server'); + } finally { + Blob.prototype.arrayBuffer = originalArrayBuffer; + } + }); + + test('should cancel an active multipart file stream when the request is aborted', async () => { + let streamCancelled = false; + + class SlowFile extends File { + override stream(): ReadableStream { + const reader = super.stream().getReader(); + + return new ReadableStream({ + async pull(controller) { + await new Promise((resolve) => setTimeout(resolve, 25)); + + const result = await reader.read(); + + if (result.done) { + controller.close(); + } else { + controller.enqueue(result.value); + } + }, + async cancel(reason) { + streamCancelled = true; + + await reader.cancel(reason); + }, + }); + } + } + + const controller = new AbortController(); + const formData = new FormData(); + + formData.append('upload', new SlowFile([Buffer.alloc(4 * 1024 * 1024)], 'slow.bin')); + + const pending = fetch(`${getBaseUrl()}/body/echo`, { + method: 'POST', + body: formData, + signal: controller.signal, + }); + + setTimeout(() => controller.abort(new Error('stop multipart upload')), 10); + + await assert.rejects(pending, (error: unknown) => { + return error instanceof Error && error.name === 'AbortError'; + }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.strictEqual(streamCancelled, true); + }); + + test('should propagate multipart source stream failures', async () => { + class BrokenFile extends File { + override stream(): ReadableStream { + throw new Error('broken multipart source'); + } + } + + const formData = new FormData(); + + formData.append('upload', new BrokenFile(['broken'], 'broken.bin')); + + await assert.rejects( + fetch(`${getBaseUrl()}/body/echo`, { + method: 'POST', + body: formData, + }), + (error: unknown) => { + return error instanceof Error && error.message.includes('broken multipart source'); + } + ); + }); + + test('should recreate multipart streams for retries', async () => { + const formData = new FormData(); + const key = `multipart-${Date.now()}-${Math.random()}`; + + formData.append('alpha', 'retry'); + formData.append('upload', new File(['retry me'], 'retry.txt', { type: 'text/plain' })); + + const response = await fetch( + `${getBaseUrl()}/retry/body?key=${encodeURIComponent(key)}&failCount=1`, + { + method: 'POST', + body: formData, + retry: { + limit: 1, + methods: ['POST'], + statusCodes: [503], + }, + } + ); + + const body = await response.json<{ attempt: number; body: string }>(); + + assert.strictEqual(body.attempt, 2); + assert.ok(body.body.includes('retry me')); + assert.ok(body.body.includes('filename="retry.txt"')); + }); + + test('should recreate multipart streams for preserving redirects', async () => { + const formData = new FormData(); + + formData.append('alpha', 'redirect'); + formData.append('upload', new File(['redirect me'], 'redirect.txt', { type: 'text/plain' })); + + const response = await fetch(`${getBaseUrl()}/redirect/preserve-body`, { + method: 'POST', + body: formData, + }); + + const body = await response.json<{ method: string; body: string }>(); + + assert.strictEqual(body.method, 'POST'); + assert.ok(body.body.includes('redirect me')); + assert.ok(body.body.includes('filename="redirect.txt"')); + }); + + test('should preserve browser-style multipart escaping and line endings', async () => { + const formData = new FormData(); + + formData.append('quoted"\nname', 'first\nsecond'); + formData.append('upload', new File(['value'], 'quoted"\nfile.txt', { type: 'text/plain' })); + + const response = await fetch(`${getBaseUrl()}/body/echo`, { + method: 'POST', + body: formData, + }); + + const body = await response.json<{ body: string }>(); + + assert.ok(body.body.includes('name="quoted%22%0D%0Aname"')); + assert.ok(body.body.includes('first\r\nsecond')); + assert.ok(body.body.includes('filename="quoted%22%0D%0Afile.txt"')); + }); + + test('should support an explicit multipart boundary', async () => { + const formData = new FormData(); + + formData.append('alpha', 'custom-boundary'); + + const response = await fetch(`${getBaseUrl()}/body/echo`, { + method: 'POST', + body: formData, + multipartBoundary: '----node-wreq-test-boundary', + }); + + const body = await response.json<{ body: string; headers: Record }>(); + + assert.strictEqual( + body.headers['content-type'], + 'multipart/form-data; boundary=----node-wreq-test-boundary' + ); + + assert.ok(body.body.startsWith('------node-wreq-test-boundary\r\n')); + assert.ok(body.body.endsWith('------node-wreq-test-boundary--\r\n')); }); test('should preserve multipart request bodies when cloning requests', async () => { @@ -86,6 +284,7 @@ describe('transport features', () => { const compressed = await fetch(`${getBaseUrl()}/headers/raw`, { browser: 'chrome_137', }); + const compressedBody = await compressed.json<{ headers: Record }>(); assert.ok( @@ -97,6 +296,7 @@ describe('transport features', () => { browser: 'chrome_137', compress: false, }); + const uncompressedBody = await uncompressed.json<{ headers: Record }>(); assert.strictEqual( @@ -135,6 +335,7 @@ describe('transport features', () => { }, }, }); + const body = await response.json<{ headers: Record }>(); assert.strictEqual(response.status, 200); @@ -149,6 +350,7 @@ describe('transport features', () => { 'session=abc123; Path=/', 'csrf=token123; Path=/', ]); + assert.strictEqual( response.headers.get('set-cookie'), 'session=abc123; Path=/, csrf=token123; Path=/' @@ -254,6 +456,7 @@ describe('transport features', () => { const defaultResponse = await fetch(`${getBaseUrl()}/headers/raw`, { browser: 'chrome_137', }); + const defaultBody = await defaultResponse.json<{ headers: Record }>(); assert.ok(defaultBody.headers['user-agent'], 'browser presets should include user-agent'); @@ -266,6 +469,7 @@ describe('transport features', () => { browser: 'chrome_137', disableDefaultHeaders: true, }); + const strippedBody = await strippedResponse.json<{ headers: Record }>(); assert.strictEqual(strippedBody.headers['user-agent'], undefined); diff --git a/src/test/websocket.spec.ts b/src/test/websocket.spec.ts index 747ab32..0b94640 100644 --- a/src/test/websocket.spec.ts +++ b/src/test/websocket.spec.ts @@ -33,6 +33,7 @@ describe('websocket', () => { const replyPromise = onceEvent(socket, 'message'); socket.send('hello'); + const replyEvent = await replyPromise; assert.strictEqual(replyEvent.data, 'hello'); @@ -40,6 +41,7 @@ describe('websocket', () => { const closePromise = onceEvent(socket, 'close'); socket.close(1000, 'done'); + const closeEvent = await closePromise; assert.strictEqual(closeEvent.code, 1000); @@ -59,6 +61,7 @@ describe('websocket', () => { const replyPromise = onceEvent(socket, 'message'); socket.send(new Uint8Array([1, 2, 3])); + const replyEvent = await replyPromise; assert.ok(replyEvent.data instanceof ArrayBuffer); @@ -67,6 +70,7 @@ describe('websocket', () => { const closePromise = onceEvent(socket, 'close'); socket.close(1000, 'done'); + await closePromise; }); @@ -91,14 +95,13 @@ describe('websocket', () => { const closePromise = onceEvent(socket, 'close'); socket.close(1000, 'done'); + await closePromise; }); test('should reject websocket URLs with fragments', () => { assert.throws( - () => { - new WreqWebSocket(getBaseUrl().replace('http://', 'ws://') + '/ws#fragment'); - }, + () => new WreqWebSocket(getBaseUrl().replace('http://', 'ws://') + '/ws#fragment'), (error: unknown) => error instanceof DOMException && error.name === 'SyntaxError', 'fragment websocket URLs should be rejected' ); @@ -106,23 +109,21 @@ describe('websocket', () => { test('should reject forbidden websocket headers and duplicate protocols', () => { assert.throws( - () => { + () => new WreqWebSocket(getBaseUrl().replace('http://', 'ws://') + '/ws', { headers: { Upgrade: 'websocket', }, - }); - }, + }), (error: unknown) => error instanceof DOMException && error.name === 'SyntaxError', 'forbidden managed websocket headers should be rejected' ); assert.throws( - () => { + () => new WreqWebSocket(getBaseUrl().replace('http://', 'ws://') + '/ws', { protocols: ['chat', 'chat'], - }); - }, + }), (error: unknown) => error instanceof SyntaxError && error.message.includes('Duplicate WebSocket subprotocol'), 'duplicate websocket subprotocols should be rejected' @@ -155,6 +156,25 @@ describe('websocket', () => { ); }); + test('should support explicit WebSocket HTTP version selection', async () => { + const socket = await websocket(getBaseUrl().replace('http://', 'ws://') + '/ws', { + httpVersion: '1.1', + }); + + const closePromise = onceEvent(socket, 'close'); + + socket.close(1000, 'done'); + + await closePromise; + + const conflicting = new WreqWebSocket(getBaseUrl().replace('http://', 'ws://') + '/ws', { + forceHttp2: true, + httpVersion: '1.1', + }); + + await assert.rejects(conflicting.opened, /forceHttp2 conflicts with httpVersion/); + }); + test('should expose negotiated websocket extensions as a string', async () => { const socket = await websocket(getBaseUrl().replace('http://', 'ws://') + '/ws'); @@ -163,6 +183,7 @@ describe('websocket', () => { const closePromise = onceEvent(socket, 'close'); socket.close(1000, 'done'); + await closePromise; }); @@ -197,14 +218,17 @@ describe('websocket', () => { ); await onceEvent(socket, 'message'); + await new Promise((resolve) => { setTimeout(resolve, 25); }); + assert.strictEqual(socket.bufferedAmount, 0, 'bufferedAmount should drain after send'); const closePromise = onceEvent(socket, 'close'); socket.close(1000, 'done'); + await closePromise; }); @@ -243,6 +267,7 @@ describe('websocket', () => { const closePromise = onceEvent(socket, 'close'); socket.close(1000, 'done'); + await closePromise; }); }); diff --git a/src/types/client.ts b/src/types/client.ts index a7ad568..7bfad38 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -1,8 +1,7 @@ import type { WebSocket } from '../websocket'; import type { Hooks } from './hooks'; import type { RequestInput, WreqInit } from './http'; -import type { HeadersInit } from './shared'; -import type { BodyInit } from './shared'; +import type { HeadersInit, BodyInit } from './shared'; import type { WebSocketInit } from './websocket'; /** Default options applied by a reusable client instance. */ @@ -26,39 +25,49 @@ export interface Client { input: RequestInput, init?: Omit ): Promise; + /** Performs a `POST` request. */ post( input: RequestInput, body?: BodyInit | null, init?: Omit ): Promise; + /** Performs a `PUT` request. */ put( input: RequestInput, body?: BodyInit | null, init?: Omit ): Promise; + /** Performs a `PATCH` request. */ patch( input: RequestInput, body?: BodyInit | null, init?: Omit ): Promise; + /** Performs a `DELETE` request. */ delete( input: RequestInput, init?: Omit ): Promise; + /** Performs a `HEAD` request. */ head( input: RequestInput, init?: Omit ): Promise; + /** Performs an `OPTIONS` request. */ options( input: RequestInput, init?: Omit ): Promise; + /** Creates a new client with merged defaults. */ extend(defaults: ClientDefaults): Client; + + /** Releases pooled native connections owned by this client. */ + close(): void; } diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 7adc939..ce132ab 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -90,10 +90,12 @@ export type InitHook = (ctx: InitContext) => void | Promise; export type BeforeRequestHook = ( ctx: BeforeRequestContext ) => void | Response | Promise; + /** Hook invoked after each response; can replace the response. */ export type AfterResponseHook = ( ctx: AfterResponseContext ) => void | Response | Promise; + /** Hook invoked before a retry delay is applied. */ export type BeforeRetryHook = (ctx: BeforeRetryContext) => void | Promise; /** Hook invoked before the final error is thrown; can replace the error. */ diff --git a/src/types/http.ts b/src/types/http.ts index ee35b34..a61eeb8 100644 --- a/src/types/http.ts +++ b/src/types/http.ts @@ -4,7 +4,7 @@ import type { Response } from '../http/response'; import type { Hooks, HookState } from './hooks'; import type { BodyInit, - BrowserProfile, + BrowserEmulation, CertificateAuthority, CookieJar, DnsOptions, @@ -100,8 +100,10 @@ export interface WreqInit { baseURL?: string; /** Query parameters appended to the final request URL. */ query?: Record; - /** Browser fingerprint profile used by the native transport. */ - browser?: BrowserProfile; + /** Browser fingerprint profile or automatic profile selection strategy. */ + browser?: BrowserEmulation; + /** Custom multipart boundary. FormData uses a WebKit-style boundary by default. */ + multipartBoundary?: string; /** Explicit proxy URL, or `false` to disable proxies entirely. */ proxy?: string | false; /** DNS overrides used for hostname resolution. */ @@ -112,6 +114,14 @@ export interface WreqInit { readTimeout?: number; /** Connection establishment timeout in milliseconds. */ connectTimeout?: number; + /** Maximum idle time for pooled connections, or `false` to disable idle expiry. */ + poolIdleTimeout?: number | false; + /** Maximum idle pooled connections retained per origin. */ + poolMaxIdlePerHost?: number; + /** Maximum total connections retained by the native client pool. */ + poolMaxSize?: number; + /** Per-origin capacity of the native TLS session cache. */ + tlsSessionCacheCapacity?: number; /** Retry policy or retry count. */ retry?: number | RetryOptions; /** Redirect handling mode. */ @@ -152,6 +162,10 @@ export interface WreqInit { http1Options?: Http1Options; /** HTTP/2 transport tuning and fingerprinting options. */ http2Options?: Http2Options; + /** Logical connection-pool partition for this request. */ + connectionGroup?: string | number; + /** Discards the connection after this response instead of returning it to the pool. */ + forbidConnectionReuse?: boolean; /** Callback invoked after each attempt with timing and outcome data. */ onStats?: (stats: RequestStats) => void | Promise; /** Mutable user-defined context copied into hook state. */ @@ -219,4 +233,6 @@ export interface WreqResponseMeta { readonly tls?: TlsPeerInfo; /** Converts the response body into a Node.js readable stream. */ readable(): import('node:stream').Readable; + /** Prevents this response's connection from returning to the native pool. */ + forbidConnectionReuse(): boolean; } diff --git a/src/types/native.ts b/src/types/native.ts index 65cc8df..c3772bc 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -75,6 +75,36 @@ export interface NativeTlsPeerInfo { peerCertificateChain?: Buffer[]; } +/** Text field in a native multipart request. */ +export interface NativeMultipartTextPart { + kind: 'text'; + name: string; + value: string; +} + +/** Streamed file field in a native multipart request. */ +export interface NativeMultipartStreamPart { + kind: 'stream'; + name: string; + fileName: string; + mimeType: string; + length: number; + uploadHandle: number; +} + +/** Multipart body assembled and streamed by the native transport. */ +export interface NativeMultipartBody { + boundary: string; + parts: Array; +} + +/** JS-side upload pump paired with a native multipart body. */ +export interface NativeMultipartUpload { + body: NativeMultipartBody; + start(signal?: AbortSignal | null): Promise; + cancel(reason?: unknown): void; +} + /** Fully normalized native request payload. */ export interface NativeRequestOptions { /** Fully resolved request URL. */ @@ -87,8 +117,20 @@ export interface NativeRequestOptions { origHeaders?: string[]; /** Encoded request body bytes. */ body?: Buffer; + /** Multipart body assembled by the native transport. */ + multipart?: NativeMultipartBody; + /** Internal JS upload pump; never read by the native options converter. */ + multipartUpload?: NativeMultipartUpload; /** Browser fingerprint profile used by the native transport. */ browser?: BrowserProfile; + /** Browser profile selection strategy used by the native transport. */ + browserMode?: 'fixed' | 'random' | 'weighted-random'; + /** Operating-system identity paired with a fixed browser profile. */ + browserPlatform?: 'windows' | 'macos' | 'linux' | 'android' | 'ios'; + /** Whether the profile's HTTP/2 fingerprint settings are applied. */ + browserHttp2?: boolean; + /** Whether the profile's default headers and ordering are applied. */ + browserHeaders?: boolean; /** Serialized emulation options passed to the native layer. */ emulationJson?: string; /** Proxy URL used for the request. */ @@ -103,6 +145,18 @@ export interface NativeRequestOptions { readTimeout?: number; /** Connection establishment timeout in milliseconds. */ connectTimeout?: number; + /** Maximum idle time for pooled connections, or `false` to disable idle expiry. */ + poolIdleTimeout?: number | false; + /** Maximum idle pooled connections retained per origin. */ + poolMaxIdlePerHost?: number; + /** Maximum total connections retained by the native client pool. */ + poolMaxSize?: number; + /** Per-origin capacity of the native TLS session cache. */ + tlsSessionCacheCapacity?: number; + /** Internal owner id for reusable native clients. */ + clientId?: number; + /** Internal transport-configuration key for reusable native clients. */ + clientCacheKey?: string; /** Disables headers normally injected by the library. */ disableDefaultHeaders?: boolean; /** Enables transparent compression handling where supported. */ @@ -125,6 +179,10 @@ export interface NativeRequestOptions { tlsDebug?: NativeTlsDebug; /** Unsafe TLS toggles intended only for controlled environments. */ tlsDanger?: NativeTlsDanger; + /** Logical connection-pool partition for this request. */ + connectionGroup?: string | number; + /** Discards the connection after this response. */ + forbidConnectionReuse?: boolean; } /** Raw response payload returned by the native layer. */ diff --git a/src/types/shared.ts b/src/types/shared.ts index ac316d9..902a870 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -1,7 +1,31 @@ +import type { BrowserProfile } from '../config/generated/browser-profiles'; import type { Buffer } from 'node:buffer'; export type { BrowserProfile } from '../config/generated/browser-profiles'; +/** Operating-system identity paired with a browser emulation profile. */ +export type BrowserPlatform = 'windows' | 'macos' | 'linux' | 'android' | 'ios'; + +/** Browser profile selection strategy. */ +export type BrowserEmulationMode = 'fixed' | 'random' | 'weighted-random'; + +/** Advanced browser emulation selection. */ +export interface BrowserEmulationOptions { + /** Selection strategy. Weighted random approximates real-world browser traffic. */ + mode?: BrowserEmulationMode; + /** Fixed profile. Only valid when `mode` is `fixed` or omitted. */ + profile?: BrowserProfile; + /** Platform identity. Only valid when `mode` is `fixed` or omitted. */ + platform?: BrowserPlatform; + /** Applies the profile's HTTP/2 fingerprint settings. */ + http2?: boolean; + /** Applies the profile's default headers and header ordering. */ + headers?: boolean; +} + +/** Browser fingerprint selection accepted by requests and clients. */ +export type BrowserEmulation = BrowserProfile | BrowserEmulationOptions; + /** Supported HTTP methods. */ export type HttpMethod = | 'GET' @@ -92,7 +116,10 @@ export interface Http2Priority { dependency: Http2StreamDependency; } -/** Extra HTTP/2 settings passed through to the transport by raw numeric id. */ +/** + * Extra HTTP/2 settings passed through to the transport by raw numeric id. + * @deprecated Upstream `wreq` no longer supports custom unknown HTTP/2 settings. + */ export interface Http2ExperimentalSetting { /** Numeric HTTP/2 setting id. */ id: number; @@ -132,6 +159,8 @@ export interface TlsOptions { pskSkipSessionTicket?: boolean; /** Limits the number of key shares sent in the handshake. */ keySharesLimit?: number; + /** Explicit ordered TLS 1.3 key-share groups. */ + keyShares?: TlsKeyShare[]; /** Enables PSK with DHE key exchange. */ pskDheKe?: boolean; /** Enables TLS renegotiation where supported. */ @@ -156,6 +185,19 @@ export interface TlsOptions { randomAesHwOverride?: boolean; } +/** TLS 1.3 key-share group exposed by the native TLS backend. */ +export type TlsKeyShare = + | 'P256' + | 'P384' + | 'P521' + | 'X25519' + | 'X25519_MLKEM768' + | 'X25519_KYBER768_DRAFT00' + | 'P256_KYBER768_DRAFT00' + | 'MLKEM1024' + | 'FFDHE2048' + | 'FFDHE3072'; + /** Client identity loaded from PEM-encoded certificate and key material. */ export interface TlsIdentityPem { /** PEM certificate chain presented during mutual TLS authentication. */ @@ -275,7 +317,10 @@ export interface Http2Options { headersStreamDependency?: Http2StreamDependency; /** Per-stream priority overrides. */ priorities?: Http2Priority[]; - /** Additional raw numeric settings passed to the connection. */ + /** + * Additional raw numeric settings passed to the connection. + * @deprecated Upstream `wreq` no longer supports custom unknown HTTP/2 settings. + */ experimentalSettings?: Http2ExperimentalSetting[]; } diff --git a/src/types/websocket.ts b/src/types/websocket.ts index 7180276..c087ae9 100644 --- a/src/types/websocket.ts +++ b/src/types/websocket.ts @@ -1,4 +1,5 @@ import type { + BrowserEmulation, BrowserProfile, CertificateAuthority, CookieJar, @@ -10,8 +11,8 @@ import type { TlsDebugOptions, TlsIdentity, TlsOptions, + HeaderTuple, } from './shared'; -import type { HeaderTuple } from './shared'; /** Binary payload representation used by incoming WebSocket messages. */ export type WebSocketBinaryType = 'blob' | 'arraybuffer'; @@ -24,14 +25,22 @@ export interface WebSocketInit { baseURL?: string; /** Query parameters appended to the final WebSocket URL. */ query?: Record; - /** Browser fingerprint profile used by the native transport. */ - browser?: BrowserProfile; + /** Browser fingerprint profile or automatic profile selection strategy. */ + browser?: BrowserEmulation; /** Explicit proxy URL, or `false` to disable proxies entirely. */ proxy?: string | false; /** DNS overrides used for hostname resolution. */ dns?: DnsOptions; /** Handshake timeout in milliseconds. */ timeout?: number; + /** Maximum idle time for pooled connections, or `false` to disable idle expiry. */ + poolIdleTimeout?: number | false; + /** Maximum idle pooled connections retained per origin. */ + poolMaxIdlePerHost?: number; + /** Maximum total connections retained by the native client pool. */ + poolMaxSize?: number; + /** Per-origin capacity of the native TLS session cache. */ + tlsSessionCacheCapacity?: number; /** Cookie jar used to populate the handshake request. */ cookieJar?: CookieJar; /** Disables headers normally injected by the library. */ @@ -54,7 +63,11 @@ export interface WebSocketInit { protocols?: string | string[]; /** Binary representation used for incoming binary messages. */ binaryType?: WebSocketBinaryType; - /** Forces the handshake to use HTTP/2 where supported. */ + /** Explicit HTTP version used for the WebSocket handshake. */ + httpVersion?: '1.1' | '2'; + /** Forces the handshake to use HTTP/2 where supported. + * @deprecated Use `httpVersion: '2'` instead. + */ forceHttp2?: boolean; /** Read buffer size used by the native socket implementation. */ readBufferSize?: number; @@ -86,6 +99,14 @@ export interface NativeWebSocketConnectOptions { origHeaders?: string[]; /** Browser fingerprint profile used by the native transport. */ browser?: BrowserProfile; + /** Browser profile selection strategy used by the native transport. */ + browserMode?: 'fixed' | 'random' | 'weighted-random'; + /** Operating-system identity paired with a fixed browser profile. */ + browserPlatform?: 'windows' | 'macos' | 'linux' | 'android' | 'ios'; + /** Whether the profile's HTTP/2 fingerprint settings are applied. */ + browserHttp2?: boolean; + /** Whether the profile's default headers and ordering are applied. */ + browserHeaders?: boolean; /** Serialized emulation options passed to the native layer. */ emulationJson?: string; /** Proxy URL used for the connection. */ @@ -96,6 +117,18 @@ export interface NativeWebSocketConnectOptions { dns?: import('./native').NativeDnsOptions; /** Handshake timeout in milliseconds. */ timeout?: number; + /** Maximum idle time for pooled connections, or `false` to disable idle expiry. */ + poolIdleTimeout?: number | false; + /** Maximum idle pooled connections retained per origin. */ + poolMaxIdlePerHost?: number; + /** Maximum total connections retained by the native client pool. */ + poolMaxSize?: number; + /** Per-origin capacity of the native TLS session cache. */ + tlsSessionCacheCapacity?: number; + /** Internal owner id for reusable native clients. */ + clientId?: number; + /** Internal transport-configuration key for reusable native clients. */ + clientCacheKey?: string; /** Disables headers normally injected by the library. */ disableDefaultHeaders?: boolean; /** Client certificate identity used for mTLS. */ @@ -110,6 +143,8 @@ export interface NativeWebSocketConnectOptions { protocols: string[]; /** Forces the handshake to use HTTP/2 where supported. */ forceHttp2?: boolean; + /** Explicit HTTP version used for the WebSocket handshake. */ + httpVersion?: '1.1' | '2'; /** Read buffer size used by the native socket implementation. */ readBufferSize?: number; /** Write buffer size used by the native socket implementation. */ diff --git a/src/websocket/index.ts b/src/websocket/index.ts index a7f4fbd..d6fd632 100644 --- a/src/websocket/index.ts +++ b/src/websocket/index.ts @@ -1,3 +1,4 @@ +import type { WebSocketBinaryType, WebSocketInit } from '../types'; import { serializeEmulationOptions } from '../config/emulation'; import { normalizeDnsOptions, @@ -12,15 +13,15 @@ import { } from '../config/tls'; import { WebSocketError } from '../errors'; import { loadCookiesIntoHeaders } from '../http/pipeline/cookies'; +import { createNativeClientCacheKey } from '../native/client-cache'; import { nativeWebSocketClose, nativeWebSocketConnect, nativeWebSocketRead, nativeWebSocketSendBinary, nativeWebSocketSendText, - validateBrowserProfile, + normalizeBrowserEmulation, } from '../native/index'; -import type { WebSocketBinaryType, WebSocketInit } from '../types'; import { CloseEvent } from './close-event'; import { getSendByteLength, normalizeSendData, toMessageEventData } from './send-data'; import { @@ -89,6 +90,10 @@ type MessageHandler = ((event: MessageEvent) => void) | null; type CloseHandler = ((event: CloseEvent) => void) | null; type ErrorHandler = ((event: Event) => void) | null; +const NATIVE_CLIENT_ID = Symbol('node-wreq.nativeClientId'); + +type InternalWebSocketInit = WebSocketInit & { [NATIVE_CLIENT_ID]?: number }; + export { CloseEvent }; /** Browser-style WebSocket implementation backed by the native transport. */ @@ -136,7 +141,8 @@ export class WebSocket extends EventTarget { super(); this.url = resolveWebSocketUrl(url, init); - validateBrowserProfile(init.browser); + normalizeBrowserEmulation(init.browser); + const headers = normalizeHeaders(init.headers); const protocols = normalizeProtocols(init.protocols); @@ -146,6 +152,7 @@ export class WebSocket extends EventTarget { 'SyntaxError' ); } + this.extensions = ''; this.#binaryType = init.binaryType ?? 'blob'; this.opened = new Promise((resolve, reject) => { @@ -153,7 +160,7 @@ export class WebSocket extends EventTarget { this.#rejectOpened = reject; }); - void this.#connect(init, headers, protocols); + void this.#connect(init, headers, protocols, (init as InternalWebSocketInit)[NATIVE_CLIENT_ID]); } /** Current WebSocket ready state. */ @@ -305,23 +312,34 @@ export class WebSocket extends EventTarget { async #connect( init: WebSocketInit, headers: import('../headers').Headers, - protocols: string[] + protocols: string[], + clientId?: number ): Promise { await loadCookiesIntoHeaders(init.cookieJar, this.url, headers); try { const localBind = normalizeLocalBindOptions(init); const { proxy, disableSystemProxy } = normalizeProxyOptions(init.proxy); - const connection = await nativeWebSocketConnect({ + const browser = normalizeBrowserEmulation(init.browser); + + if (init.forceHttp2 && init.httpVersion === '1.1') { + throw new TypeError("forceHttp2 conflicts with httpVersion: '1.1'"); + } + + const nativeOptions: import('../types').NativeWebSocketConnectOptions = { url: this.url, headers: headers.toTuples(), origHeaders: headers.toOriginalNames(), - browser: init.browser, + ...browser, emulationJson: serializeEmulationOptions(init), proxy, disableSystemProxy, dns: normalizeDnsOptions(init.dns), ...resolveNativeTimeout(init.timeout), + poolIdleTimeout: init.poolIdleTimeout, + poolMaxIdlePerHost: init.poolMaxIdlePerHost, + poolMaxSize: init.poolMaxSize, + tlsSessionCacheCapacity: init.tlsSessionCacheCapacity, disableDefaultHeaders: init.disableDefaultHeaders ?? false, tlsIdentity: normalizeTlsIdentity(init.tlsIdentity), ca: normalizeCertificateAuthority(init.ca), @@ -329,6 +347,7 @@ export class WebSocket extends EventTarget { tlsDanger: normalizeTlsDanger(init.tlsDanger), protocols, forceHttp2: init.forceHttp2, + httpVersion: init.httpVersion, acceptUnmaskedFrames: init.acceptUnmaskedFrames, ...resolveNativeWebSocketBufferSize(init.readBufferSize, 'readBufferSize'), ...resolveNativeWebSocketBufferSize(init.writeBufferSize, 'writeBufferSize'), @@ -336,10 +355,18 @@ export class WebSocket extends EventTarget { ...resolveNativeWebSocketSize(init.maxFrameSize, 'maxFrameSize'), ...resolveNativeWebSocketSize(init.maxMessageSize, 'maxMessageSize'), ...localBind, - }); + }; + + if (clientId !== undefined) { + nativeOptions.clientId = clientId; + nativeOptions.clientCacheKey = createNativeClientCacheKey(nativeOptions); + } + + const connection = await nativeWebSocketConnect(nativeOptions); this.#handle = connection.handle; this.#protocol = connection.protocol ?? ''; + if (connection.protocol && protocols.length > 0 && !protocols.includes(connection.protocol)) { throw new WebSocketError(`Server selected unexpected subprotocol: ${connection.protocol}`); } @@ -390,17 +417,17 @@ export class WebSocket extends EventTarget { } } - #setEventHandler( + #setEventHandler( type: string, - current: ((event: any) => void) | null, - next: ((event: any) => void) | null + current: ((event: T) => void) | null, + next: ((event: T) => void) | null ): void { if (current) { - this.removeEventListener(type, current); + this.removeEventListener(type, current as (event: Event) => void); } if (next) { - this.addEventListener(type, next); + this.addEventListener(type, next as (event: Event) => void); } } @@ -428,6 +455,7 @@ export class WebSocket extends EventTarget { this.#settled = true; this.#readyState = WebSocket.CLOSED; + if (this.#handle !== undefined) { this.#handle = undefined; } @@ -442,7 +470,19 @@ export class WebSocket extends EventTarget { /** Connects a WebSocket and resolves once the socket is open. */ export async function websocket(url: string | URL, init?: WebSocketInit): Promise { - const socket = new WebSocket(url, init); + return websocketWithNativeClient(url, init); +} + +/** @internal Connects a WebSocket using a reusable native client owner. */ +export async function websocketWithNativeClient( + url: string | URL, + init?: WebSocketInit, + clientId?: number +): Promise { + const internalInit: InternalWebSocketInit = + clientId === undefined ? (init ?? {}) : { ...init, [NATIVE_CLIENT_ID]: clientId }; + + const socket = new WebSocket(url, internalInit); await socket.opened; diff --git a/src/websocket/send-data.ts b/src/websocket/send-data.ts index f390ff7..24e64cb 100644 --- a/src/websocket/send-data.ts +++ b/src/websocket/send-data.ts @@ -1,5 +1,5 @@ -import { Buffer } from 'node:buffer'; import type { NativeWebSocketReadResult, WebSocketBinaryType } from '../types'; +import { Buffer } from 'node:buffer'; export function getSendByteLength(data: string | Blob | ArrayBuffer | ArrayBufferView): number { if (typeof data === 'string') { @@ -72,6 +72,7 @@ export function toMessageEventData( case 'text': { return result.data; } + case 'binary': { if (binaryType === 'arraybuffer') { const bytes = result.data; @@ -81,6 +82,7 @@ export function toMessageEventData( return new Blob([result.data]); } + case 'close': { throw new TypeError('Close frames cannot be converted to message events'); } diff --git a/src/websocket/validation.ts b/src/websocket/validation.ts index d87843c..31dec6d 100644 --- a/src/websocket/validation.ts +++ b/src/websocket/validation.ts @@ -1,7 +1,7 @@ +import type { HeadersInit, WebSocketInit } from '../types'; import { Buffer } from 'node:buffer'; import { WebSocketError } from '../errors'; import { Headers } from '../headers'; -import type { HeadersInit, WebSocketInit } from '../types'; const SUBPROTOCOL_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; const FORBIDDEN_WEBSOCKET_HEADERS = new Set([ diff --git a/tsconfig.json b/tsconfig.json index ad96c68..b2bf84f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2020", "module": "commonjs", - "lib": ["ES2020"], + "lib": ["ES2020", "ES2021.WeakRef"], "declaration": true, "declarationMap": true, "sourceMap": true,