diff --git a/README.md b/README.md index 73f0fb2..aa0fb18 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ - ⚡ **Native Performance** — 50-100x faster than curl-impersonate (no process spawning) - 🔒 **TLS Fingerprinting** — Perfect JA3/JA4 signatures matching real browsers - 🌐 **HTTP/2 Fingerprinting** — Authentic SETTINGS frames, PRIORITY, and header ordering -- 🎭 **Multiple Browser Profiles** — Chrome, Firefox, Safari, Edge +- 🎭 **Multiple Browser Profiles** — Chrome, Firefox, Safari, Edge, Opera, OkHttp (78+ profiles) +- 🔌 **WebSocket Support** - 📦 **Zero Dependencies** — Pure Rust with BoringSSL under the hood - 💎 **TypeScript Support** — Full type definitions included - 🛡️ **Protection Bypass** — Cloudflare, Akamai, and other anti-bot systems @@ -118,12 +119,41 @@ import { request } from 'node-wreq'; const response = await request({ url: 'https://example.com', browser: 'chrome_137', - proxy?: 'http://proxy.example.com:8080', - // or with authentication: + // proxy: 'http://proxy.example.com:8080', // proxy: 'http://username:password@proxy.example.com:8080', + // proxy: 'socks5://proxy.example.com:1080', }); ``` +### WebSocket Connection + +```typescript +import { websocket } from 'node-wreq'; + +const ws = await websocket({ + url: 'wss://echo.websocket.org', + browser: 'chrome_137', + onMessage: (data) => { + console.log('Received:', data); + }, + onClose: () => { + console.log('Connection closed'); + }, + onError: (error) => { + console.error('Error:', error); + }, +}); + +// Send text message +await ws.send('Hello!'); + +// Send binary message +await ws.send(Buffer.from([1, 2, 3])); + +// Close connection +await ws.close(); +``` + ## 📚 API Reference ### `request(options:` [`RequestOptions`](#requestoptions)`): Promise<`[`Response`](#response)`>` @@ -162,6 +192,33 @@ interface Response { ### `post(url: string, body?: string, options?): Promise<`[`Response`](#response)`>` +### `websocket(options:` [`WebSocketOptions`](#websocketoptions)`): Promise` + + +**Options:** + + +```typescript +interface WebSocketOptions { + url: string; // Required: WebSocket URL (ws:// or wss://) + browser?: BrowserProfile; // Default: 'chrome_137' + headers?: Record; + proxy?: string; // HTTP/HTTPS/SOCKS5 proxy URL + onMessage: (data: string | Buffer) => void; // Required: Message callback + onClose?: () => void; // Optional: Close callback + onError?: (error: string) => void; // Optional: Error callback +} +``` + +**WebSocket Methods:** + +```typescript +class WebSocket { + send(data: string | Buffer): Promise; + close(): Promise; +} +``` + ### `getProfiles():` [`BrowserProfile[]`](#browser-profiles) Get list of available browser profiles. @@ -170,6 +227,7 @@ Get list of available browser profiles. import { getProfiles } from 'node-wreq'; const profiles = getProfiles(); + console.log(profiles); // ['chrome_100', 'chrome_101', ..., 'chrome_137', 'edge_101', ..., 'safari_18', ...] ``` diff --git a/package-lock.json b/package-lock.json index a08b9be..67bc8ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "node-wreq", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "node-wreq", - "version": "0.1.0", + "version": "0.2.0", "cpu": [ "x64", "arm64" @@ -21,10 +21,52 @@ "@napi-rs/cli": "^2.18.0", "@types/node": "^20.0.0", "prettier": "^3.2.5", + "rimraf": "^6.0.1", "typescript": "^5.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" } }, "node_modules/@napi-rs/cli": { @@ -50,6 +92,225 @@ "undici-types": "~6.21.0" } }, + "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/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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" + }, + "node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/prettier": { "version": "3.6.2", "dev": true, @@ -64,6 +325,166 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/rimraf": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", + "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^11.0.0", + "package-json-from-dist": "^1.0.0" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "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/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/typescript": { "version": "5.9.3", "dev": true, @@ -80,6 +501,120 @@ "version": "6.21.0", "dev": true, "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/package.json b/package.json index 733459b..38238a2 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,17 @@ { "name": "node-wreq", - "version": "0.1.2", + "version": "0.2.0", "description": "Browser fingerprint bypass library using Rust for TLS/HTTP2 impersonation", "main": "dist/node-wreq.js", "types": "dist/node-wreq.d.ts", "scripts": { "build": "npm run build:rust && npm run build:ts", "build:rust": "napi build --platform --release --cargo-cwd rust rust", - "build:ts": "tsc", + "build:ts": "npm run clean:dist && tsc", + "clean:dist": "rimraf dist", "artifacts": "napi artifacts", - "clean": "rm -rf dist rust/target rust/*.node", - "test": "npm run build && node --test dist/test/node-wreq.spec.js", + "clean": "rimraf dist rust/target rust/*.node", + "test": "npm run build && node --test dist/test/*.spec.js", "format": "prettier --write \"src/**/*.{ts,js,json,md}\"", "format:check": "prettier --check \"src/**/*.{ts,js,json,md}\"" }, @@ -22,7 +23,14 @@ "tls", "http2", "impersonation", - "web-scraping" + "web-scraping", + "crawler", + "web-scraper", + "wreq", + "ja3", + "tls-fingerprint", + "ja4", + "browser-fingerprint-bypass" ], "author": "will-work-for-meal", "license": "MIT", @@ -34,15 +42,15 @@ "url": "https://github.com/will-work-for-meal/node-wreq/issues" }, "homepage": "https://github.com/will-work-for-meal/node-wreq#readme", - "dependencies": {}, "devDependencies": { - "@types/node": "^20.0.0", - "typescript": "^5.0.0", "@napi-rs/cli": "^2.18.0", - "prettier": "^3.2.5" + "@types/node": "^20.0.0", + "prettier": "^3.2.5", + "rimraf": "^6.0.1", + "typescript": "^5.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "os": [ "darwin", diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 8bf0e39..0482723 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -123,6 +123,15 @@ version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "boring-sys2" version = "5.0.0-alpha.10" @@ -253,6 +262,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -262,6 +280,22 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "deranged" version = "0.5.4" @@ -271,6 +305,16 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "either" version = "1.15.0" @@ -372,6 +416,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -391,9 +446,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", ] [[package]] @@ -598,6 +666,12 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + [[package]] name = "memchr" version = "2.7.6" @@ -663,10 +737,12 @@ name = "node-wreq" version = "0.1.0" dependencies = [ "anyhow", + "futures-util", "neon", + "once_cell", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "wreq", "wreq-util", @@ -767,6 +843,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -791,6 +876,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -937,6 +1051,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1003,7 +1128,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", ] [[package]] @@ -1017,6 +1151,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "time" version = "0.3.44" @@ -1098,8 +1243,20 @@ checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" dependencies = [ "either", "futures-util", - "thiserror", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +dependencies = [ + "futures-util", + "log", "tokio", + "tungstenite", ] [[package]] @@ -1168,6 +1325,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + [[package]] name = "typed-builder" version = "0.22.0" @@ -1188,12 +1362,24 @@ dependencies = [ "syn", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "version_check" version = "0.9.5" @@ -1382,6 +1568,7 @@ dependencies = [ "tokio", "tokio-boring2", "tokio-socks", + "tokio-tungstenite", "tower", "tower-http", "want", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index dd2591e..1e87f3c 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -9,9 +9,12 @@ crate-type = ["cdylib"] [dependencies] # HTTP client with browser impersonation -wreq = { version = "6.0.0-rc.20", default-features = false, features = ["gzip", "deflate", "socks", "cookies", "json", "webpki-roots"] } +wreq = { version = "6.0.0-rc.20", default-features = false, features = ["gzip", "deflate", "socks", "cookies", "json", "webpki-roots", "ws"] } wreq-util = "3.0.0-rc.1" +# WebSocket support +futures-util = "0.3" + # Neon for Node.js bindings neon = { version = "1.0", default-features = false, features = ["napi-6"] } @@ -26,6 +29,9 @@ thiserror = "1.0" # Async runtime (if needed) tokio = { version = "1.0", features = ["full"] } +# Global state management +once_cell = "1.20" + [profile.release] opt-level = 3 lto = true diff --git a/rust/src/lib.rs b/rust/src/lib.rs index f5e6611..f794f25 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,9 +1,15 @@ mod client; +mod websocket; use neon::prelude::*; +use neon::types::buffer::TypedArray; use client::{make_request, RequestOptions, Response}; +use websocket::{connect_websocket, store_connection, get_connection, remove_connection, WebSocketOptions, WS_RUNTIME}; use std::collections::HashMap; +use std::sync::Arc; use wreq_util::Emulation; +use futures_util::StreamExt; +use wreq::ws::message::Message; // Parse browser string to Emulation enum fn parse_emulation(browser: &str) -> Emulation { @@ -270,10 +276,272 @@ fn get_profiles(mut cx: FunctionContext) -> JsResult { Ok(js_array) } +// WebSocket connection function +fn websocket_connect(mut cx: FunctionContext) -> JsResult { + // Get the options object + let options_obj = cx.argument::(0)?; + + // Get URL (required) + let url: Handle = options_obj.get(&mut cx, "url")?; + let url = url.value(&mut cx); + + // Get browser (optional, defaults to chrome_137) + let browser_str = options_obj + .get_opt(&mut cx, "browser")? + .and_then(|v: Handle| v.downcast::(&mut cx).ok()) + .map(|v| v.value(&mut cx)) + .unwrap_or_else(|| "chrome_137".to_string()); + + let emulation = parse_emulation(&browser_str); + + // Get headers (optional) + let mut headers = HashMap::new(); + if let Ok(Some(headers_obj)) = options_obj.get_opt::(&mut cx, "headers") { + let keys = headers_obj.get_own_property_names(&mut cx)?; + let keys_vec = keys.to_vec(&mut cx)?; + + for key_val in keys_vec { + if let Ok(key_str) = key_val.downcast::(&mut cx) { + let key = key_str.value(&mut cx); + if let Ok(value) = headers_obj.get::(&mut cx, key.as_str()) { + headers.insert(key, value.value(&mut cx)); + } + } + } + } + + // Get proxy (optional) + let proxy = options_obj + .get_opt(&mut cx, "proxy")? + .and_then(|v: Handle| v.downcast::(&mut cx).ok()) + .map(|v| v.value(&mut cx)); + + // Get callbacks + let on_message: Handle = options_obj.get(&mut cx, "onMessage")?; + let on_close_opt = options_obj + .get_opt::(&mut cx, "onClose")?; + let on_error_opt = options_obj + .get_opt::(&mut cx, "onError")?; + + let options = WebSocketOptions { + url, + emulation, + headers, + proxy, + }; + + // Create a promise + let channel = cx.channel(); + let (deferred, promise) = cx.promise(); + + // Keep callbacks alive + let on_message = Arc::new(on_message.root(&mut cx)); + let on_close = on_close_opt.map(|f| Arc::new(f.root(&mut cx))); + let on_error = on_error_opt.map(|f| Arc::new(f.root(&mut cx))); + + // Spawn async task + std::thread::spawn(move || { + let result: Result<_, anyhow::Error> = WS_RUNTIME.block_on(async { + // Connect to WebSocket + let (connection, mut receiver) = connect_websocket(options).await?; + + // Start message receiver loop + let channel_clone = channel.clone(); + let on_message_clone = on_message.clone(); + let on_close_clone = on_close.clone(); + let on_error_clone = on_error.clone(); + + tokio::spawn(async move { + while let Some(msg_result) = receiver.next().await { + match msg_result { + Ok(msg) => { + match msg { + Message::Text(text) => { + let text = text.to_string(); + let on_message_ref = on_message_clone.clone(); + channel_clone.send(move |mut cx| { + let cb = on_message_ref.to_inner(&mut cx); + let this = cx.undefined(); + let args = vec![cx.string(text).upcast()]; + cb.call(&mut cx, this, args)?; + Ok(()) + }); + } + Message::Binary(data) => { + let data = data.to_vec(); + let on_message_ref = on_message_clone.clone(); + channel_clone.send(move |mut cx| { + let cb = on_message_ref.to_inner(&mut cx); + let this = cx.undefined(); + let mut buffer = cx.buffer(data.len())?; + buffer.as_mut_slice(&mut cx).copy_from_slice(&data); + let args = vec![buffer.upcast()]; + cb.call(&mut cx, this, args)?; + Ok(()) + }); + } + Message::Close(_) => { + if let Some(on_close_ref) = on_close_clone.as_ref() { + let on_close_ref = on_close_ref.clone(); + channel_clone.send(move |mut cx| { + let cb = on_close_ref.to_inner(&mut cx); + let this = cx.undefined(); + cb.call(&mut cx, this, vec![])?; + Ok(()) + }); + } + break; + } + _ => { + // Ignore Ping/Pong messages + } + } + } + Err(e) => { + if let Some(on_error_ref) = on_error_clone.as_ref() { + let error_msg = format!("{:#}", e); + let on_error_ref = on_error_ref.clone(); + channel_clone.send(move |mut cx| { + let cb = on_error_ref.to_inner(&mut cx); + let this = cx.undefined(); + let args = vec![cx.string(error_msg).upcast()]; + cb.call(&mut cx, this, args)?; + Ok(()) + }); + } + break; + } + } + } + }); + + Ok(connection) + }); + + // Send result back to JS + deferred.settle_with(&channel, move |mut cx| { + match result { + Ok(connection) => { + // Store connection and get ID + let id = store_connection(connection); + + let obj = cx.empty_object(); + let id_num = cx.number(id as f64); + obj.set(&mut cx, "_id", id_num)?; + Ok(obj) + } + Err(e) => { + let error_msg = format!("{:#}", e); + cx.throw_error(error_msg) + } + } + }); + }); + + Ok(promise) +} + +// WebSocket send function +fn websocket_send(mut cx: FunctionContext) -> JsResult { + let ws_obj = cx.argument::(0)?; + let data = cx.argument::(1)?; + + // Get the connection ID from the object + let id_val: Handle = ws_obj.get(&mut cx, "_id")?; + let id = id_val.value(&mut cx) as u64; + + // Get connection from global storage + let connection = match get_connection(id) { + Some(conn) => conn, + None => return cx.throw_error("WebSocket connection not found"), + }; + + let channel = cx.channel(); + let (deferred, promise) = cx.promise(); + + // Check if data is string or buffer + let is_text = data.is_a::(&mut cx); + let send_data = if is_text { + let text = data.downcast_or_throw::(&mut cx)?; + SendData::Text(text.value(&mut cx)) + } else if let Ok(buffer) = data.downcast::(&mut cx) { + let data = buffer.as_slice(&cx).to_vec(); + SendData::Binary(data) + } else { + return cx.throw_error("Data must be a string or Buffer"); + }; + + std::thread::spawn(move || { + let result = WS_RUNTIME.block_on(async { + match send_data { + SendData::Text(text) => connection.send_text(text).await, + SendData::Binary(data) => connection.send_binary(data).await, + } + }); + + deferred.settle_with(&channel, move |mut cx| { + match result { + Ok(()) => Ok(cx.undefined()), + Err(e) => { + let error_msg = format!("{:#}", e); + cx.throw_error(error_msg) + } + } + }); + }); + + Ok(promise) +} + +enum SendData { + Text(String), + Binary(Vec), +} + +// WebSocket close function +fn websocket_close(mut cx: FunctionContext) -> JsResult { + let ws_obj = cx.argument::(0)?; + + // Get the connection ID from the object + let id_val: Handle = ws_obj.get(&mut cx, "_id")?; + let id = id_val.value(&mut cx) as u64; + + // Get connection from global storage + let connection = match get_connection(id) { + Some(conn) => conn, + None => return cx.throw_error("WebSocket connection not found"), + }; + + let channel = cx.channel(); + let (deferred, promise) = cx.promise(); + + std::thread::spawn(move || { + let result = WS_RUNTIME.block_on(connection.close()); + + // Remove connection from storage after closing + remove_connection(id); + + deferred.settle_with(&channel, move |mut cx| { + match result { + Ok(()) => Ok(cx.undefined()), + Err(e) => { + let error_msg = format!("{:#}", e); + cx.throw_error(error_msg) + } + } + }); + }); + + Ok(promise) +} + // Module initialization #[neon::main] fn main(mut cx: ModuleContext) -> NeonResult<()> { cx.export_function("request", request)?; cx.export_function("getProfiles", get_profiles)?; + cx.export_function("websocketConnect", websocket_connect)?; + cx.export_function("websocketSend", websocket_send)?; + cx.export_function("websocketClose", websocket_close)?; Ok(()) } diff --git a/rust/src/websocket.rs b/rust/src/websocket.rs new file mode 100644 index 0000000..12bce25 --- /dev/null +++ b/rust/src/websocket.rs @@ -0,0 +1,146 @@ +use anyhow::{Context, Result}; +use futures_util::{SinkExt, StreamExt}; +use neon::prelude::*; +use std::collections::HashMap; +use std::sync::{Arc, Mutex as StdMutex}; +use tokio::sync::Mutex; +use wreq::ws::message::Message; +use wreq::ws::WebSocket; +use wreq_util::Emulation; +use once_cell::sync::Lazy; + +// Global storage for WebSocket connections +static WS_CONNECTIONS: Lazy>>> = + Lazy::new(|| StdMutex::new(HashMap::new())); + +static NEXT_WS_ID: Lazy> = Lazy::new(|| StdMutex::new(1)); + +// Global Tokio runtime for WebSocket operations +pub static WS_RUNTIME: Lazy = Lazy::new(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("Failed to create Tokio runtime for WebSockets") +}); + +#[derive(Debug, Clone)] +pub struct WebSocketOptions { + pub url: String, + pub emulation: Emulation, + pub headers: HashMap, + pub proxy: Option, +} + +/// WebSocket connection wrapper +pub struct WsConnection { + sender: Arc>>, +} + +impl WsConnection { + pub fn new( + sender: futures_util::stream::SplitSink, + ) -> Self { + Self { + sender: Arc::new(Mutex::new(sender)), + } + } + + /// Send a text message + pub async fn send_text(&self, text: String) -> Result<()> { + let mut sender = self.sender.lock().await; + sender + .send(Message::text(text)) + .await + .context("Failed to send text message")?; + Ok(()) + } + + /// Send a binary message + pub async fn send_binary(&self, data: Vec) -> Result<()> { + let mut sender = self.sender.lock().await; + sender + .send(Message::binary(data)) + .await + .context("Failed to send binary message")?; + Ok(()) + } + + /// Close the WebSocket connection + pub async fn close(&self) -> Result<()> { + let mut sender = self.sender.lock().await; + sender + .send(Message::close(None)) + .await + .context("Failed to close WebSocket")?; + Ok(()) + } +} + +// Finalize implementation for proper cleanup +impl Finalize for WsConnection {} + +/// Store a WebSocket connection and return its ID +pub fn store_connection(connection: WsConnection) -> u64 { + let mut id_lock = NEXT_WS_ID.lock().unwrap(); + let id = *id_lock; + *id_lock += 1; + drop(id_lock); + + let mut connections = WS_CONNECTIONS.lock().unwrap(); + connections.insert(id, Arc::new(connection)); + id +} + +/// Get a WebSocket connection by ID +pub fn get_connection(id: u64) -> Option> { + let connections = WS_CONNECTIONS.lock().unwrap(); + connections.get(&id).cloned() +} + +/// Remove a WebSocket connection +pub fn remove_connection(id: u64) { + let mut connections = WS_CONNECTIONS.lock().unwrap(); + connections.remove(&id); +} + +/// Create WebSocket connection +pub async fn connect_websocket(options: WebSocketOptions) -> Result<(WsConnection, futures_util::stream::SplitStream)> { + // Build client with emulation and proxy + let mut client_builder = wreq::Client::builder() + .emulation(options.emulation); + + // Apply proxy if present + if let Some(proxy_url) = &options.proxy { + let proxy = wreq::Proxy::all(proxy_url).context("Failed to create proxy")?; + client_builder = client_builder.proxy(proxy); + } + + // Build the client + let client = client_builder + .build() + .context("Failed to build HTTP client")?; + + // Create WebSocket request + let mut request = client.websocket(&options.url); + + // Apply custom headers + for (key, value) in &options.headers { + request = request.header(key, value); + } + + // Send upgrade request + let ws_response = request + .send() + .await + .context("Failed to send WebSocket upgrade request")?; + + // Upgrade to WebSocket + let websocket = ws_response.into_websocket().await?; + + // Split into sender and receiver + let (sender, receiver) = websocket.split(); + + let connection = WsConnection::new(sender); + + Ok((connection, receiver)) +} diff --git a/src/node-wreq.ts b/src/node-wreq.ts index 52acd13..ca12bde 100644 --- a/src/node-wreq.ts +++ b/src/node-wreq.ts @@ -1,9 +1,28 @@ -import type { RequestOptions, Response, BrowserProfile } from './types'; +import type { + RequestOptions, + Response, + BrowserProfile, + WebSocketOptions, + NativeWebSocketConnection, +} from './types'; import { RequestError } from './types'; +interface NativeWebSocketOptions { + url: string; + browser: BrowserProfile; + headers: Record; + proxy?: string; + onMessage: (data: string | Buffer) => void; + onClose?: () => void; + onError?: (error: string) => void; +} + let nativeBinding: { request: (options: RequestOptions) => Promise; getProfiles: () => string[]; + websocketConnect: (options: NativeWebSocketOptions) => Promise; + websocketSend: (ws: NativeWebSocketConnection, data: string | Buffer) => Promise; + websocketClose: (ws: NativeWebSocketConnection) => Promise; }; function loadNativeBinding() { @@ -26,6 +45,7 @@ function loadNativeBinding() { }; const platformArch = platformArchMap[platform]?.[arch]; + if (!platformArch) { throw new Error( `Unsupported platform: ${platform}-${arch}. ` + @@ -87,6 +107,7 @@ export async function request(options: RequestOptions): Promise { if (options.browser) { const profiles = getProfiles(); + if (!profiles.includes(options.browser)) { throw new RequestError( `Invalid browser profile: ${options.browser}. Available profiles: ${profiles.join(', ')}` @@ -166,7 +187,116 @@ export async function post( return request({ ...options, url, method: 'POST', body }); } -export type { RequestOptions, Response, BrowserProfile, HttpMethod } from './types'; +/** + * WebSocket connection class + * + * @example + * ```typescript + * import { websocket } from 'node-wreq'; + * + * const ws = await websocket({ + * url: 'wss://echo.websocket.org', + * browser: 'chrome_137', + * onMessage: (data) => { + * console.log('Received:', data); + * }, + * onClose: () => { + * console.log('Connection closed'); + * }, + * onError: (error) => { + * console.error('Error:', error); + * } + * }); + * + * // Send text message + * await ws.send('Hello World'); + * + * // Send binary message + * await ws.send(Buffer.from([1, 2, 3])); + * + * // Close connection + * await ws.close(); + * ``` + */ +export class WebSocket { + private _connection: NativeWebSocketConnection; + + constructor(connection: NativeWebSocketConnection) { + this._connection = connection; + } + + /** + * Send a message (text or binary) + */ + async send(data: string | Buffer): Promise { + try { + await nativeBinding.websocketSend(this._connection, data); + } catch (error) { + throw new RequestError(String(error)); + } + } + + /** + * Close the WebSocket connection + */ + async close(): Promise { + try { + await nativeBinding.websocketClose(this._connection); + } catch (error) { + throw new RequestError(String(error)); + } + } +} + +/** + * Create a WebSocket connection with browser impersonation + * + * @param options - WebSocket options + * @returns Promise that resolves to the WebSocket instance + */ +export async function websocket(options: WebSocketOptions): Promise { + if (!options.url) { + throw new RequestError('URL is required'); + } + + if (!options.onMessage) { + throw new RequestError('onMessage callback is required'); + } + + if (options.browser) { + const profiles = getProfiles(); + + if (!profiles.includes(options.browser)) { + throw new RequestError( + `Invalid browser profile: ${options.browser}. Available profiles: ${profiles.join(', ')}` + ); + } + } + + try { + const connection = await nativeBinding.websocketConnect({ + url: options.url, + browser: options.browser || 'chrome_137', + headers: options.headers || {}, + proxy: options.proxy, + onMessage: options.onMessage, + onClose: options.onClose, + onError: options.onError, + }); + + return new WebSocket(connection); + } catch (error) { + throw new RequestError(String(error)); + } +} + +export type { + RequestOptions, + Response, + BrowserProfile, + HttpMethod, + WebSocketOptions, +} from './types'; export type { RequestError }; @@ -175,4 +305,6 @@ export default { get, post, getProfiles, + websocket, + WebSocket, }; diff --git a/src/test/node-wreq.spec.ts b/src/test/http.spec.ts similarity index 81% rename from src/test/node-wreq.spec.ts rename to src/test/http.spec.ts index da8381d..226c773 100644 --- a/src/test/node-wreq.spec.ts +++ b/src/test/http.spec.ts @@ -2,9 +2,9 @@ import { test, describe, before } from 'node:test'; import assert from 'node:assert'; import { request, getProfiles } from '../node-wreq'; -describe('node-wreq', () => { +describe('HTTP', () => { before(() => { - console.log('🚀 node-wreq - Test Suite\n'); + console.log('🔌 HTTP Test Suite\n'); }); test('should return available browser profiles', () => { @@ -13,13 +13,13 @@ describe('node-wreq', () => { 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'), + profiles.some((p) => p.includes('chrome')) || + profiles.some((p) => p.includes('firefox')) || + profiles.some((p) => p.includes('safari')), 'Should include standard browser profiles' ); - console.log(' Available profiles:', profiles.join(', ')); + console.log('Available profiles:', profiles.join(', ')); }); test('should make a simple GET request', async () => { @@ -34,10 +34,11 @@ describe('node-wreq', () => { assert.ok(response.body.length > 0, 'Should have response body'); const body = JSON.parse(response.body); + assert.ok(body.headers['User-Agent'], 'Should have User-Agent header'); - console.log(' Status:', response.status); - console.log(' User-Agent:', body.headers['User-Agent']); + console.log('Status:', response.status); + console.log('User-Agent:', body.headers['User-Agent']); }); test('should work with different browser profiles', async () => { @@ -54,9 +55,10 @@ describe('node-wreq', () => { assert.ok(response.status === 200, `${browser} should return status 200`); const data = JSON.parse(response.body); + assert.ok(data['user-agent'], `${browser} should have user-agent`); - console.log(` ${browser}:`, data['user-agent'].substring(0, 50) + '...'); + console.log(`${browser}:`, data['user-agent'].substring(0, 70) + '...'); } }); diff --git a/src/test/websocket.spec.ts b/src/test/websocket.spec.ts new file mode 100644 index 0000000..d059c70 --- /dev/null +++ b/src/test/websocket.spec.ts @@ -0,0 +1,149 @@ +import { test, describe, before } from 'node:test'; +import assert from 'node:assert'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { websocket } from '../node-wreq'; + +describe('WebSocket', () => { + before(() => { + console.log('🔌 WebSocket Test Suite\n'); + }); + + test('should connect to WebSocket and send/receive messages', async () => { + const messages: (string | Buffer)[] = []; + let isClosed = false; + + const ws = await websocket({ + url: 'wss://echo.websocket.org', + browser: 'chrome_137', + onMessage: (data) => { + messages.push(data); + }, + onClose: () => { + isClosed = true; + }, + onError: (error) => { + console.error('WebSocket error:', error); + }, + }); + + console.log('WebSocket connected'); + + await ws.send('Hello!'); + + // Wait for echo response + await sleep(1000); + assert.ok(messages.length > 0, 'Should receive at least one message'); + + // Wait a bit for close callback + await ws.close(); + await sleep(500); + assert.ok(isClosed, 'Should receive close event'); + + // Rate limit protection: wait before next test + await sleep(2000); + }); + + test('should handle parallel sends on same WebSocket', async () => { + const messages: (string | Buffer)[] = []; + const expectedMessages = ['Message 1', 'Message 2', 'Message 3', 'Message 4', 'Message 5']; + + const ws = await websocket({ + url: 'wss://echo.websocket.org', + browser: 'chrome_137', + onMessage: (data) => { + messages.push(data); + }, + onClose: () => {}, + onError: (error) => { + console.error('WebSocket error:', error); + }, + }); + + console.log('Testing parallel sends...'); + + // Send multiple messages in parallel + await Promise.all([ + ws.send('Message 1'), + ws.send('Message 2'), + ws.send('Message 3'), + ws.send('Message 4'), + ws.send('Message 5'), + ]); + + console.log('All messages sent in parallel'); + + // Wait for echo responses + await sleep(2000); + + assert.ok(messages.length >= 5, 'Should receive at least 5 messages'); + + // Verify that all expected messages were received (order may vary) + const receivedStrings = messages.map((m) => (Buffer.isBuffer(m) ? m.toString() : m)); + + for (const expected of expectedMessages) { + assert.ok( + receivedStrings.includes(expected), + `Should receive message: "${expected}". Got: ${receivedStrings.join(', ')}` + ); + } + console.log('All messages received correctly:', receivedStrings.join(', ')); + + await ws.close(); + + // Rate limit protection: wait before next test + await sleep(2000); + }); + + test('should handle multiple WebSocket connections simultaneously', async () => { + const ws1Messages: (string | Buffer)[] = []; + const ws2Messages: (string | Buffer)[] = []; + + // Create two WebSocket connections in parallel + const [ws1, ws2] = await Promise.all([ + websocket({ + url: 'wss://echo.websocket.org', + browser: 'chrome_137', + onMessage: (data) => ws1Messages.push(data), + onClose: () => {}, + onError: () => {}, + }), + websocket({ + url: 'wss://echo.websocket.org', + browser: 'firefox_139', + onMessage: (data) => ws2Messages.push(data), + onClose: () => {}, + onError: () => {}, + }), + ]); + + console.log('WebSocket connections created'); + + // Send unique messages on both connections in parallel + await Promise.all([ws1.send('From WS1'), ws2.send('From WS2')]); + + // Wait for responses + await sleep(1500); + + assert.ok(ws1Messages.length > 0, 'WS1 should receive messages'); + assert.ok(ws2Messages.length > 0, 'WS2 should receive messages'); + + // Verify that each connection received the correct message (not mixed up) + // Note: echo.websocket.org sends a "Request served by..." message first, then echoes + const ws1Strings = ws1Messages.map((m) => (Buffer.isBuffer(m) ? m.toString() : m)); + const ws2Strings = ws2Messages.map((m) => (Buffer.isBuffer(m) ? m.toString() : m)); + + assert.ok(ws1Strings.includes('From WS1'), 'WS1 should receive its own message'); + assert.ok(ws2Strings.includes('From WS2'), 'WS2 should receive its own message'); + + // Verify messages are not mixed up between connections + assert.ok(!ws1Strings.includes('From WS2'), 'WS1 should NOT receive WS2 message'); + assert.ok(!ws2Strings.includes('From WS1'), 'WS2 should NOT receive WS1 message'); + + console.log('Messages correctly isolated between connections:'); + console.log(' WS1:', ws1Strings); + console.log(' WS2:', ws2Strings); + + // Close both connections + await Promise.all([ws1.close(), ws2.close()]); + }); +}); diff --git a/src/types.ts b/src/types.ts index 55a5912..4fbdf46 100644 --- a/src/types.ts +++ b/src/types.ts @@ -163,6 +163,54 @@ export interface Response { url: string; } +/** + * WebSocket options for creating a connection + */ +export interface WebSocketOptions { + /** + * The WebSocket URL to connect to (wss:// or ws://) + */ + url: string; + + /** + * Browser profile to impersonate + * @default 'chrome_137' + */ + browser?: BrowserProfile; + + /** + * Additional headers to send with the WebSocket upgrade request + */ + headers?: Record; + + /** + * Proxy URL (e.g., 'http://proxy.example.com:8080') + */ + proxy?: string; + + /** + * Callback for incoming messages (required) + */ + onMessage: (data: string | Buffer) => void; + + /** + * Callback for connection close event + */ + onClose?: () => void; + + /** + * Callback for error events + */ + onError?: (error: string) => void; +} + +/** + * Internal WebSocket connection object returned from native binding + */ +export interface NativeWebSocketConnection { + _id: number; +} + export class RequestError extends Error { constructor(message: string) { super(message);