icebear0828 Claude Opus 4.6 commited on
Commit
3d01305
·
1 Parent(s): d6c3bb0

feat: add libcurl-impersonate FFI transport for Chrome TLS fingerprint

Browse files

Windows lacks curl-impersonate CLI, so use koffi FFI to call
libcurl-impersonate DLL directly. Introduces TlsTransport abstraction
with auto-selection: FFI on Windows, CLI subprocess on macOS/Linux.
All three API protocols (OpenAI/Anthropic/Gemini) verified working.

- New: src/tls/transport.ts (interface + singleton factory)
- New: src/tls/curl-cli-transport.ts (extracted from codex-api.ts)
- New: src/tls/libcurl-ffi-transport.ts (koffi FFI, curl_multi streaming)
- New: scripts/poc-libcurl-ffi.ts (POC verification script)
- Updated: setup-curl.ts auto-downloads DLL + cacert.pem on Windows
- Updated: codex-api.ts uses transport abstraction (~200 lines removed)
- Updated: curl-fetch.ts delegates to transport singleton

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

config/default.yaml CHANGED
@@ -38,6 +38,9 @@ session:
38
  cleanup_interval_minutes: 5
39
 
40
  tls:
 
 
 
41
  # curl binary path. "auto" = detect bin/curl-impersonate-chrome, fallback to system curl
42
  curl_binary: "auto"
43
  # Chrome profile for --impersonate flag (auto-detected when curl-impersonate supports it)
 
38
  cleanup_interval_minutes: 5
39
 
40
  tls:
41
+ # Transport layer: "auto" | "curl-cli" | "libcurl-ffi"
42
+ # auto = Windows+DLL → libcurl-ffi, macOS/Linux → curl-cli
43
+ transport: "auto"
44
  # curl binary path. "auto" = detect bin/curl-impersonate-chrome, fallback to system curl
45
  curl_binary: "auto"
46
  # Chrome profile for --impersonate flag (auto-detected when curl-impersonate supports it)
package-lock.json CHANGED
@@ -7,18 +7,43 @@
7
  "": {
8
  "name": "codex-proxy",
9
  "version": "1.0.0",
 
10
  "dependencies": {
11
  "@hono/node-server": "^1.0.0",
12
  "hono": "^4.0.0",
13
  "js-yaml": "^4.1.0",
 
14
  "undici": "^7.0.0",
15
  "zod": "^3.23.0"
16
  },
17
  "devDependencies": {
 
18
  "@types/js-yaml": "^4.0.0",
19
  "@types/node": "^22.0.0",
 
20
  "tsx": "^4.0.0",
21
  "typescript": "^5.5.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  }
23
  },
24
  "node_modules/@esbuild/aix-ppc64": {
@@ -475,6 +500,42 @@
475
  "hono": "^4"
476
  }
477
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  "node_modules/@types/js-yaml": {
479
  "version": "4.0.9",
480
  "resolved": "https://registry.npmmirror.com/@types/js-yaml/-/js-yaml-4.0.9.tgz",
@@ -492,12 +553,198 @@
492
  "undici-types": "~6.21.0"
493
  }
494
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
  "node_modules/argparse": {
496
  "version": "2.0.1",
497
  "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
498
  "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
499
  "license": "Python-2.0"
500
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  "node_modules/esbuild": {
502
  "version": "0.27.3",
503
  "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz",
@@ -540,6 +787,30 @@
540
  "@esbuild/win32-x64": "0.27.3"
541
  }
542
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
  "node_modules/fsevents": {
544
  "version": "2.3.3",
545
  "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
@@ -568,6 +839,28 @@
568
  "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
569
  }
570
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571
  "node_modules/hono": {
572
  "version": "4.11.9",
573
  "resolved": "https://registry.npmmirror.com/hono/-/hono-4.11.9.tgz",
@@ -577,6 +870,144 @@
577
  "node": ">=16.9.0"
578
  }
579
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  "node_modules/js-yaml": {
581
  "version": "4.1.1",
582
  "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -589,6 +1020,124 @@
589
  "js-yaml": "bin/js-yaml.js"
590
  }
591
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  "node_modules/resolve-pkg-maps": {
593
  "version": "1.0.0",
594
  "resolved": "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -599,6 +1148,159 @@
599
  "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
600
  }
601
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
602
  "node_modules/tsx": {
603
  "version": "4.21.0",
604
  "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.21.0.tgz",
@@ -649,6 +1351,127 @@
649
  "dev": true,
650
  "license": "MIT"
651
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  "node_modules/zod": {
653
  "version": "3.25.76",
654
  "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
 
7
  "": {
8
  "name": "codex-proxy",
9
  "version": "1.0.0",
10
+ "hasInstallScript": true,
11
  "dependencies": {
12
  "@hono/node-server": "^1.0.0",
13
  "hono": "^4.0.0",
14
  "js-yaml": "^4.1.0",
15
+ "koffi": "^2.15.1",
16
  "undici": "^7.0.0",
17
  "zod": "^3.23.0"
18
  },
19
  "devDependencies": {
20
+ "@electron/asar": "^3.2.0",
21
  "@types/js-yaml": "^4.0.0",
22
  "@types/node": "^22.0.0",
23
+ "js-beautify": "^1.15.0",
24
  "tsx": "^4.0.0",
25
  "typescript": "^5.5.0"
26
+ },
27
+ "optionalDependencies": {
28
+ "koffi": "^2.15.1"
29
+ }
30
+ },
31
+ "node_modules/@electron/asar": {
32
+ "version": "3.4.1",
33
+ "resolved": "https://registry.npmmirror.com/@electron/asar/-/asar-3.4.1.tgz",
34
+ "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==",
35
+ "dev": true,
36
+ "license": "MIT",
37
+ "dependencies": {
38
+ "commander": "^5.0.0",
39
+ "glob": "^7.1.6",
40
+ "minimatch": "^3.0.4"
41
+ },
42
+ "bin": {
43
+ "asar": "bin/asar.js"
44
+ },
45
+ "engines": {
46
+ "node": ">=10.12.0"
47
  }
48
  },
49
  "node_modules/@esbuild/aix-ppc64": {
 
500
  "hono": "^4"
501
  }
502
  },
503
+ "node_modules/@isaacs/cliui": {
504
+ "version": "8.0.2",
505
+ "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz",
506
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
507
+ "dev": true,
508
+ "license": "ISC",
509
+ "dependencies": {
510
+ "string-width": "^5.1.2",
511
+ "string-width-cjs": "npm:string-width@^4.2.0",
512
+ "strip-ansi": "^7.0.1",
513
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
514
+ "wrap-ansi": "^8.1.0",
515
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
516
+ },
517
+ "engines": {
518
+ "node": ">=12"
519
+ }
520
+ },
521
+ "node_modules/@one-ini/wasm": {
522
+ "version": "0.1.1",
523
+ "resolved": "https://registry.npmmirror.com/@one-ini/wasm/-/wasm-0.1.1.tgz",
524
+ "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
525
+ "dev": true,
526
+ "license": "MIT"
527
+ },
528
+ "node_modules/@pkgjs/parseargs": {
529
+ "version": "0.11.0",
530
+ "resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
531
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
532
+ "dev": true,
533
+ "license": "MIT",
534
+ "optional": true,
535
+ "engines": {
536
+ "node": ">=14"
537
+ }
538
+ },
539
  "node_modules/@types/js-yaml": {
540
  "version": "4.0.9",
541
  "resolved": "https://registry.npmmirror.com/@types/js-yaml/-/js-yaml-4.0.9.tgz",
 
553
  "undici-types": "~6.21.0"
554
  }
555
  },
556
+ "node_modules/abbrev": {
557
+ "version": "2.0.0",
558
+ "resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-2.0.0.tgz",
559
+ "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
560
+ "dev": true,
561
+ "license": "ISC",
562
+ "engines": {
563
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
564
+ }
565
+ },
566
+ "node_modules/ansi-regex": {
567
+ "version": "6.2.2",
568
+ "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz",
569
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
570
+ "dev": true,
571
+ "license": "MIT",
572
+ "engines": {
573
+ "node": ">=12"
574
+ },
575
+ "funding": {
576
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
577
+ }
578
+ },
579
+ "node_modules/ansi-styles": {
580
+ "version": "6.2.3",
581
+ "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz",
582
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
583
+ "dev": true,
584
+ "license": "MIT",
585
+ "engines": {
586
+ "node": ">=12"
587
+ },
588
+ "funding": {
589
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
590
+ }
591
+ },
592
  "node_modules/argparse": {
593
  "version": "2.0.1",
594
  "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
595
  "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
596
  "license": "Python-2.0"
597
  },
598
+ "node_modules/balanced-match": {
599
+ "version": "1.0.2",
600
+ "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
601
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
602
+ "dev": true,
603
+ "license": "MIT"
604
+ },
605
+ "node_modules/brace-expansion": {
606
+ "version": "1.1.12",
607
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz",
608
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
609
+ "dev": true,
610
+ "license": "MIT",
611
+ "dependencies": {
612
+ "balanced-match": "^1.0.0",
613
+ "concat-map": "0.0.1"
614
+ }
615
+ },
616
+ "node_modules/color-convert": {
617
+ "version": "2.0.1",
618
+ "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
619
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
620
+ "dev": true,
621
+ "license": "MIT",
622
+ "dependencies": {
623
+ "color-name": "~1.1.4"
624
+ },
625
+ "engines": {
626
+ "node": ">=7.0.0"
627
+ }
628
+ },
629
+ "node_modules/color-name": {
630
+ "version": "1.1.4",
631
+ "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
632
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
633
+ "dev": true,
634
+ "license": "MIT"
635
+ },
636
+ "node_modules/commander": {
637
+ "version": "5.1.0",
638
+ "resolved": "https://registry.npmmirror.com/commander/-/commander-5.1.0.tgz",
639
+ "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
640
+ "dev": true,
641
+ "license": "MIT",
642
+ "engines": {
643
+ "node": ">= 6"
644
+ }
645
+ },
646
+ "node_modules/concat-map": {
647
+ "version": "0.0.1",
648
+ "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz",
649
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
650
+ "dev": true,
651
+ "license": "MIT"
652
+ },
653
+ "node_modules/config-chain": {
654
+ "version": "1.1.13",
655
+ "resolved": "https://registry.npmmirror.com/config-chain/-/config-chain-1.1.13.tgz",
656
+ "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
657
+ "dev": true,
658
+ "license": "MIT",
659
+ "dependencies": {
660
+ "ini": "^1.3.4",
661
+ "proto-list": "~1.2.1"
662
+ }
663
+ },
664
+ "node_modules/cross-spawn": {
665
+ "version": "7.0.6",
666
+ "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
667
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
668
+ "dev": true,
669
+ "license": "MIT",
670
+ "dependencies": {
671
+ "path-key": "^3.1.0",
672
+ "shebang-command": "^2.0.0",
673
+ "which": "^2.0.1"
674
+ },
675
+ "engines": {
676
+ "node": ">= 8"
677
+ }
678
+ },
679
+ "node_modules/eastasianwidth": {
680
+ "version": "0.2.0",
681
+ "resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
682
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
683
+ "dev": true,
684
+ "license": "MIT"
685
+ },
686
+ "node_modules/editorconfig": {
687
+ "version": "1.0.4",
688
+ "resolved": "https://registry.npmmirror.com/editorconfig/-/editorconfig-1.0.4.tgz",
689
+ "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
690
+ "dev": true,
691
+ "license": "MIT",
692
+ "dependencies": {
693
+ "@one-ini/wasm": "0.1.1",
694
+ "commander": "^10.0.0",
695
+ "minimatch": "9.0.1",
696
+ "semver": "^7.5.3"
697
+ },
698
+ "bin": {
699
+ "editorconfig": "bin/editorconfig"
700
+ },
701
+ "engines": {
702
+ "node": ">=14"
703
+ }
704
+ },
705
+ "node_modules/editorconfig/node_modules/brace-expansion": {
706
+ "version": "2.0.2",
707
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz",
708
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
709
+ "dev": true,
710
+ "license": "MIT",
711
+ "dependencies": {
712
+ "balanced-match": "^1.0.0"
713
+ }
714
+ },
715
+ "node_modules/editorconfig/node_modules/commander": {
716
+ "version": "10.0.1",
717
+ "resolved": "https://registry.npmmirror.com/commander/-/commander-10.0.1.tgz",
718
+ "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
719
+ "dev": true,
720
+ "license": "MIT",
721
+ "engines": {
722
+ "node": ">=14"
723
+ }
724
+ },
725
+ "node_modules/editorconfig/node_modules/minimatch": {
726
+ "version": "9.0.1",
727
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.1.tgz",
728
+ "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
729
+ "dev": true,
730
+ "license": "ISC",
731
+ "dependencies": {
732
+ "brace-expansion": "^2.0.1"
733
+ },
734
+ "engines": {
735
+ "node": ">=16 || 14 >=14.17"
736
+ },
737
+ "funding": {
738
+ "url": "https://github.com/sponsors/isaacs"
739
+ }
740
+ },
741
+ "node_modules/emoji-regex": {
742
+ "version": "9.2.2",
743
+ "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz",
744
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
745
+ "dev": true,
746
+ "license": "MIT"
747
+ },
748
  "node_modules/esbuild": {
749
  "version": "0.27.3",
750
  "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz",
 
787
  "@esbuild/win32-x64": "0.27.3"
788
  }
789
  },
790
+ "node_modules/foreground-child": {
791
+ "version": "3.3.1",
792
+ "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz",
793
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
794
+ "dev": true,
795
+ "license": "ISC",
796
+ "dependencies": {
797
+ "cross-spawn": "^7.0.6",
798
+ "signal-exit": "^4.0.1"
799
+ },
800
+ "engines": {
801
+ "node": ">=14"
802
+ },
803
+ "funding": {
804
+ "url": "https://github.com/sponsors/isaacs"
805
+ }
806
+ },
807
+ "node_modules/fs.realpath": {
808
+ "version": "1.0.0",
809
+ "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz",
810
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
811
+ "dev": true,
812
+ "license": "ISC"
813
+ },
814
  "node_modules/fsevents": {
815
  "version": "2.3.3",
816
  "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
 
839
  "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
840
  }
841
  },
842
+ "node_modules/glob": {
843
+ "version": "7.2.3",
844
+ "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
845
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
846
+ "deprecated": "Glob versions prior to v9 are no longer supported",
847
+ "dev": true,
848
+ "license": "ISC",
849
+ "dependencies": {
850
+ "fs.realpath": "^1.0.0",
851
+ "inflight": "^1.0.4",
852
+ "inherits": "2",
853
+ "minimatch": "^3.1.1",
854
+ "once": "^1.3.0",
855
+ "path-is-absolute": "^1.0.0"
856
+ },
857
+ "engines": {
858
+ "node": "*"
859
+ },
860
+ "funding": {
861
+ "url": "https://github.com/sponsors/isaacs"
862
+ }
863
+ },
864
  "node_modules/hono": {
865
  "version": "4.11.9",
866
  "resolved": "https://registry.npmmirror.com/hono/-/hono-4.11.9.tgz",
 
870
  "node": ">=16.9.0"
871
  }
872
  },
873
+ "node_modules/inflight": {
874
+ "version": "1.0.6",
875
+ "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz",
876
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
877
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
878
+ "dev": true,
879
+ "license": "ISC",
880
+ "dependencies": {
881
+ "once": "^1.3.0",
882
+ "wrappy": "1"
883
+ }
884
+ },
885
+ "node_modules/inherits": {
886
+ "version": "2.0.4",
887
+ "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
888
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
889
+ "dev": true,
890
+ "license": "ISC"
891
+ },
892
+ "node_modules/ini": {
893
+ "version": "1.3.8",
894
+ "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz",
895
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
896
+ "dev": true,
897
+ "license": "ISC"
898
+ },
899
+ "node_modules/is-fullwidth-code-point": {
900
+ "version": "3.0.0",
901
+ "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
902
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
903
+ "dev": true,
904
+ "license": "MIT",
905
+ "engines": {
906
+ "node": ">=8"
907
+ }
908
+ },
909
+ "node_modules/isexe": {
910
+ "version": "2.0.0",
911
+ "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
912
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
913
+ "dev": true,
914
+ "license": "ISC"
915
+ },
916
+ "node_modules/jackspeak": {
917
+ "version": "3.4.3",
918
+ "resolved": "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz",
919
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
920
+ "dev": true,
921
+ "license": "BlueOak-1.0.0",
922
+ "dependencies": {
923
+ "@isaacs/cliui": "^8.0.2"
924
+ },
925
+ "funding": {
926
+ "url": "https://github.com/sponsors/isaacs"
927
+ },
928
+ "optionalDependencies": {
929
+ "@pkgjs/parseargs": "^0.11.0"
930
+ }
931
+ },
932
+ "node_modules/js-beautify": {
933
+ "version": "1.15.4",
934
+ "resolved": "https://registry.npmmirror.com/js-beautify/-/js-beautify-1.15.4.tgz",
935
+ "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==",
936
+ "dev": true,
937
+ "license": "MIT",
938
+ "dependencies": {
939
+ "config-chain": "^1.1.13",
940
+ "editorconfig": "^1.0.4",
941
+ "glob": "^10.4.2",
942
+ "js-cookie": "^3.0.5",
943
+ "nopt": "^7.2.1"
944
+ },
945
+ "bin": {
946
+ "css-beautify": "js/bin/css-beautify.js",
947
+ "html-beautify": "js/bin/html-beautify.js",
948
+ "js-beautify": "js/bin/js-beautify.js"
949
+ },
950
+ "engines": {
951
+ "node": ">=14"
952
+ }
953
+ },
954
+ "node_modules/js-beautify/node_modules/brace-expansion": {
955
+ "version": "2.0.2",
956
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz",
957
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
958
+ "dev": true,
959
+ "license": "MIT",
960
+ "dependencies": {
961
+ "balanced-match": "^1.0.0"
962
+ }
963
+ },
964
+ "node_modules/js-beautify/node_modules/glob": {
965
+ "version": "10.4.5",
966
+ "resolved": "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz",
967
+ "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
968
+ "dev": true,
969
+ "license": "ISC",
970
+ "dependencies": {
971
+ "foreground-child": "^3.1.0",
972
+ "jackspeak": "^3.1.2",
973
+ "minimatch": "^9.0.4",
974
+ "minipass": "^7.1.2",
975
+ "package-json-from-dist": "^1.0.0",
976
+ "path-scurry": "^1.11.1"
977
+ },
978
+ "bin": {
979
+ "glob": "dist/esm/bin.mjs"
980
+ },
981
+ "funding": {
982
+ "url": "https://github.com/sponsors/isaacs"
983
+ }
984
+ },
985
+ "node_modules/js-beautify/node_modules/minimatch": {
986
+ "version": "9.0.5",
987
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz",
988
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
989
+ "dev": true,
990
+ "license": "ISC",
991
+ "dependencies": {
992
+ "brace-expansion": "^2.0.1"
993
+ },
994
+ "engines": {
995
+ "node": ">=16 || 14 >=14.17"
996
+ },
997
+ "funding": {
998
+ "url": "https://github.com/sponsors/isaacs"
999
+ }
1000
+ },
1001
+ "node_modules/js-cookie": {
1002
+ "version": "3.0.5",
1003
+ "resolved": "https://registry.npmmirror.com/js-cookie/-/js-cookie-3.0.5.tgz",
1004
+ "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
1005
+ "dev": true,
1006
+ "license": "MIT",
1007
+ "engines": {
1008
+ "node": ">=14"
1009
+ }
1010
+ },
1011
  "node_modules/js-yaml": {
1012
  "version": "4.1.1",
1013
  "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz",
 
1020
  "js-yaml": "bin/js-yaml.js"
1021
  }
1022
  },
1023
+ "node_modules/koffi": {
1024
+ "version": "2.15.1",
1025
+ "resolved": "https://registry.npmmirror.com/koffi/-/koffi-2.15.1.tgz",
1026
+ "integrity": "sha512-mnc0C0crx/xMSljb5s9QbnLrlFHprioFO1hkXyuSuO/QtbpLDa0l/uM21944UfQunMKmp3/r789DTDxVyyH6aA==",
1027
+ "hasInstallScript": true,
1028
+ "license": "MIT",
1029
+ "optional": true,
1030
+ "funding": {
1031
+ "url": "https://liberapay.com/Koromix"
1032
+ }
1033
+ },
1034
+ "node_modules/lru-cache": {
1035
+ "version": "10.4.3",
1036
+ "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz",
1037
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
1038
+ "dev": true,
1039
+ "license": "ISC"
1040
+ },
1041
+ "node_modules/minimatch": {
1042
+ "version": "3.1.2",
1043
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
1044
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
1045
+ "dev": true,
1046
+ "license": "ISC",
1047
+ "dependencies": {
1048
+ "brace-expansion": "^1.1.7"
1049
+ },
1050
+ "engines": {
1051
+ "node": "*"
1052
+ }
1053
+ },
1054
+ "node_modules/minipass": {
1055
+ "version": "7.1.3",
1056
+ "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz",
1057
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
1058
+ "dev": true,
1059
+ "license": "BlueOak-1.0.0",
1060
+ "engines": {
1061
+ "node": ">=16 || 14 >=14.17"
1062
+ }
1063
+ },
1064
+ "node_modules/nopt": {
1065
+ "version": "7.2.1",
1066
+ "resolved": "https://registry.npmmirror.com/nopt/-/nopt-7.2.1.tgz",
1067
+ "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
1068
+ "dev": true,
1069
+ "license": "ISC",
1070
+ "dependencies": {
1071
+ "abbrev": "^2.0.0"
1072
+ },
1073
+ "bin": {
1074
+ "nopt": "bin/nopt.js"
1075
+ },
1076
+ "engines": {
1077
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
1078
+ }
1079
+ },
1080
+ "node_modules/once": {
1081
+ "version": "1.4.0",
1082
+ "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
1083
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
1084
+ "dev": true,
1085
+ "license": "ISC",
1086
+ "dependencies": {
1087
+ "wrappy": "1"
1088
+ }
1089
+ },
1090
+ "node_modules/package-json-from-dist": {
1091
+ "version": "1.0.1",
1092
+ "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
1093
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
1094
+ "dev": true,
1095
+ "license": "BlueOak-1.0.0"
1096
+ },
1097
+ "node_modules/path-is-absolute": {
1098
+ "version": "1.0.1",
1099
+ "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
1100
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
1101
+ "dev": true,
1102
+ "license": "MIT",
1103
+ "engines": {
1104
+ "node": ">=0.10.0"
1105
+ }
1106
+ },
1107
+ "node_modules/path-key": {
1108
+ "version": "3.1.1",
1109
+ "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
1110
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
1111
+ "dev": true,
1112
+ "license": "MIT",
1113
+ "engines": {
1114
+ "node": ">=8"
1115
+ }
1116
+ },
1117
+ "node_modules/path-scurry": {
1118
+ "version": "1.11.1",
1119
+ "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz",
1120
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
1121
+ "dev": true,
1122
+ "license": "BlueOak-1.0.0",
1123
+ "dependencies": {
1124
+ "lru-cache": "^10.2.0",
1125
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
1126
+ },
1127
+ "engines": {
1128
+ "node": ">=16 || 14 >=14.18"
1129
+ },
1130
+ "funding": {
1131
+ "url": "https://github.com/sponsors/isaacs"
1132
+ }
1133
+ },
1134
+ "node_modules/proto-list": {
1135
+ "version": "1.2.4",
1136
+ "resolved": "https://registry.npmmirror.com/proto-list/-/proto-list-1.2.4.tgz",
1137
+ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
1138
+ "dev": true,
1139
+ "license": "ISC"
1140
+ },
1141
  "node_modules/resolve-pkg-maps": {
1142
  "version": "1.0.0",
1143
  "resolved": "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
 
1148
  "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
1149
  }
1150
  },
1151
+ "node_modules/semver": {
1152
+ "version": "7.7.4",
1153
+ "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
1154
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
1155
+ "dev": true,
1156
+ "license": "ISC",
1157
+ "bin": {
1158
+ "semver": "bin/semver.js"
1159
+ },
1160
+ "engines": {
1161
+ "node": ">=10"
1162
+ }
1163
+ },
1164
+ "node_modules/shebang-command": {
1165
+ "version": "2.0.0",
1166
+ "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
1167
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
1168
+ "dev": true,
1169
+ "license": "MIT",
1170
+ "dependencies": {
1171
+ "shebang-regex": "^3.0.0"
1172
+ },
1173
+ "engines": {
1174
+ "node": ">=8"
1175
+ }
1176
+ },
1177
+ "node_modules/shebang-regex": {
1178
+ "version": "3.0.0",
1179
+ "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
1180
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
1181
+ "dev": true,
1182
+ "license": "MIT",
1183
+ "engines": {
1184
+ "node": ">=8"
1185
+ }
1186
+ },
1187
+ "node_modules/signal-exit": {
1188
+ "version": "4.1.0",
1189
+ "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz",
1190
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
1191
+ "dev": true,
1192
+ "license": "ISC",
1193
+ "engines": {
1194
+ "node": ">=14"
1195
+ },
1196
+ "funding": {
1197
+ "url": "https://github.com/sponsors/isaacs"
1198
+ }
1199
+ },
1200
+ "node_modules/string-width": {
1201
+ "version": "5.1.2",
1202
+ "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz",
1203
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
1204
+ "dev": true,
1205
+ "license": "MIT",
1206
+ "dependencies": {
1207
+ "eastasianwidth": "^0.2.0",
1208
+ "emoji-regex": "^9.2.2",
1209
+ "strip-ansi": "^7.0.1"
1210
+ },
1211
+ "engines": {
1212
+ "node": ">=12"
1213
+ },
1214
+ "funding": {
1215
+ "url": "https://github.com/sponsors/sindresorhus"
1216
+ }
1217
+ },
1218
+ "node_modules/string-width-cjs": {
1219
+ "name": "string-width",
1220
+ "version": "4.2.3",
1221
+ "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
1222
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
1223
+ "dev": true,
1224
+ "license": "MIT",
1225
+ "dependencies": {
1226
+ "emoji-regex": "^8.0.0",
1227
+ "is-fullwidth-code-point": "^3.0.0",
1228
+ "strip-ansi": "^6.0.1"
1229
+ },
1230
+ "engines": {
1231
+ "node": ">=8"
1232
+ }
1233
+ },
1234
+ "node_modules/string-width-cjs/node_modules/ansi-regex": {
1235
+ "version": "5.0.1",
1236
+ "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
1237
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
1238
+ "dev": true,
1239
+ "license": "MIT",
1240
+ "engines": {
1241
+ "node": ">=8"
1242
+ }
1243
+ },
1244
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
1245
+ "version": "8.0.0",
1246
+ "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
1247
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
1248
+ "dev": true,
1249
+ "license": "MIT"
1250
+ },
1251
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
1252
+ "version": "6.0.1",
1253
+ "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
1254
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
1255
+ "dev": true,
1256
+ "license": "MIT",
1257
+ "dependencies": {
1258
+ "ansi-regex": "^5.0.1"
1259
+ },
1260
+ "engines": {
1261
+ "node": ">=8"
1262
+ }
1263
+ },
1264
+ "node_modules/strip-ansi": {
1265
+ "version": "7.1.2",
1266
+ "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.2.tgz",
1267
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
1268
+ "dev": true,
1269
+ "license": "MIT",
1270
+ "dependencies": {
1271
+ "ansi-regex": "^6.0.1"
1272
+ },
1273
+ "engines": {
1274
+ "node": ">=12"
1275
+ },
1276
+ "funding": {
1277
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
1278
+ }
1279
+ },
1280
+ "node_modules/strip-ansi-cjs": {
1281
+ "name": "strip-ansi",
1282
+ "version": "6.0.1",
1283
+ "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
1284
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
1285
+ "dev": true,
1286
+ "license": "MIT",
1287
+ "dependencies": {
1288
+ "ansi-regex": "^5.0.1"
1289
+ },
1290
+ "engines": {
1291
+ "node": ">=8"
1292
+ }
1293
+ },
1294
+ "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
1295
+ "version": "5.0.1",
1296
+ "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
1297
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
1298
+ "dev": true,
1299
+ "license": "MIT",
1300
+ "engines": {
1301
+ "node": ">=8"
1302
+ }
1303
+ },
1304
  "node_modules/tsx": {
1305
  "version": "4.21.0",
1306
  "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.21.0.tgz",
 
1351
  "dev": true,
1352
  "license": "MIT"
1353
  },
1354
+ "node_modules/which": {
1355
+ "version": "2.0.2",
1356
+ "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
1357
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
1358
+ "dev": true,
1359
+ "license": "ISC",
1360
+ "dependencies": {
1361
+ "isexe": "^2.0.0"
1362
+ },
1363
+ "bin": {
1364
+ "node-which": "bin/node-which"
1365
+ },
1366
+ "engines": {
1367
+ "node": ">= 8"
1368
+ }
1369
+ },
1370
+ "node_modules/wrap-ansi": {
1371
+ "version": "8.1.0",
1372
+ "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
1373
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
1374
+ "dev": true,
1375
+ "license": "MIT",
1376
+ "dependencies": {
1377
+ "ansi-styles": "^6.1.0",
1378
+ "string-width": "^5.0.1",
1379
+ "strip-ansi": "^7.0.1"
1380
+ },
1381
+ "engines": {
1382
+ "node": ">=12"
1383
+ },
1384
+ "funding": {
1385
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
1386
+ }
1387
+ },
1388
+ "node_modules/wrap-ansi-cjs": {
1389
+ "name": "wrap-ansi",
1390
+ "version": "7.0.0",
1391
+ "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
1392
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
1393
+ "dev": true,
1394
+ "license": "MIT",
1395
+ "dependencies": {
1396
+ "ansi-styles": "^4.0.0",
1397
+ "string-width": "^4.1.0",
1398
+ "strip-ansi": "^6.0.0"
1399
+ },
1400
+ "engines": {
1401
+ "node": ">=10"
1402
+ },
1403
+ "funding": {
1404
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
1405
+ }
1406
+ },
1407
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
1408
+ "version": "5.0.1",
1409
+ "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
1410
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
1411
+ "dev": true,
1412
+ "license": "MIT",
1413
+ "engines": {
1414
+ "node": ">=8"
1415
+ }
1416
+ },
1417
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
1418
+ "version": "4.3.0",
1419
+ "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
1420
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
1421
+ "dev": true,
1422
+ "license": "MIT",
1423
+ "dependencies": {
1424
+ "color-convert": "^2.0.1"
1425
+ },
1426
+ "engines": {
1427
+ "node": ">=8"
1428
+ },
1429
+ "funding": {
1430
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
1431
+ }
1432
+ },
1433
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
1434
+ "version": "8.0.0",
1435
+ "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
1436
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
1437
+ "dev": true,
1438
+ "license": "MIT"
1439
+ },
1440
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
1441
+ "version": "4.2.3",
1442
+ "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
1443
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
1444
+ "dev": true,
1445
+ "license": "MIT",
1446
+ "dependencies": {
1447
+ "emoji-regex": "^8.0.0",
1448
+ "is-fullwidth-code-point": "^3.0.0",
1449
+ "strip-ansi": "^6.0.1"
1450
+ },
1451
+ "engines": {
1452
+ "node": ">=8"
1453
+ }
1454
+ },
1455
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
1456
+ "version": "6.0.1",
1457
+ "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
1458
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
1459
+ "dev": true,
1460
+ "license": "MIT",
1461
+ "dependencies": {
1462
+ "ansi-regex": "^5.0.1"
1463
+ },
1464
+ "engines": {
1465
+ "node": ">=8"
1466
+ }
1467
+ },
1468
+ "node_modules/wrappy": {
1469
+ "version": "1.0.2",
1470
+ "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
1471
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
1472
+ "dev": true,
1473
+ "license": "ISC"
1474
+ },
1475
  "node_modules/zod": {
1476
  "version": "3.25.76",
1477
  "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
package.json CHANGED
@@ -19,18 +19,21 @@
19
  "postinstall": "tsx scripts/setup-curl.ts"
20
  },
21
  "dependencies": {
22
- "hono": "^4.0.0",
23
  "@hono/node-server": "^1.0.0",
24
- "undici": "^7.0.0",
25
  "js-yaml": "^4.1.0",
 
26
  "zod": "^3.23.0"
27
  },
 
 
 
28
  "devDependencies": {
29
- "typescript": "^5.5.0",
30
- "tsx": "^4.0.0",
31
  "@types/js-yaml": "^4.0.0",
32
  "@types/node": "^22.0.0",
33
  "js-beautify": "^1.15.0",
34
- "@electron/asar": "^3.2.0"
 
35
  }
36
  }
 
19
  "postinstall": "tsx scripts/setup-curl.ts"
20
  },
21
  "dependencies": {
 
22
  "@hono/node-server": "^1.0.0",
23
+ "hono": "^4.0.0",
24
  "js-yaml": "^4.1.0",
25
+ "undici": "^7.0.0",
26
  "zod": "^3.23.0"
27
  },
28
+ "optionalDependencies": {
29
+ "koffi": "^2.15.1"
30
+ },
31
  "devDependencies": {
32
+ "@electron/asar": "^3.2.0",
 
33
  "@types/js-yaml": "^4.0.0",
34
  "@types/node": "^22.0.0",
35
  "js-beautify": "^1.15.0",
36
+ "tsx": "^4.0.0",
37
+ "typescript": "^5.5.0"
38
  }
39
  }
scripts/poc-libcurl-ffi.ts ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * POC: Verify libcurl-impersonate FFI transport works on Windows.
4
+ *
5
+ * Tests:
6
+ * 1. koffi can load libcurl.dll
7
+ * 2. curl_easy_impersonate("chrome136") works
8
+ * 3. WRITEFUNCTION callback receives data
9
+ * 4. TLS fingerprint matches Chrome (checked via tls.peet.ws)
10
+ * 5. curl_multi async loop doesn't block event loop
11
+ */
12
+
13
+ import { resolve } from "path";
14
+ import { existsSync } from "fs";
15
+
16
+ const BIN_DIR = resolve(process.cwd(), "bin");
17
+
18
+ async function main() {
19
+ console.log("=== libcurl-impersonate FFI POC ===\n");
20
+
21
+ // 1. Load koffi
22
+ console.log("[1/5] Loading koffi...");
23
+ let koffi: any;
24
+ try {
25
+ koffi = (await import("koffi")).default ?? await import("koffi");
26
+ console.log(" ✓ koffi loaded");
27
+ } catch (err: any) {
28
+ console.error(" ✗ koffi not available:", err.message);
29
+ process.exit(1);
30
+ }
31
+
32
+ // 2. Load libcurl.dll
33
+ console.log("[2/5] Loading libcurl.dll...");
34
+ const dllPath = resolve(BIN_DIR, "libcurl.dll");
35
+ if (!existsSync(dllPath)) {
36
+ console.error(` ✗ ${dllPath} not found. Run: npm run setup`);
37
+ process.exit(1);
38
+ }
39
+ let lib: any;
40
+ try {
41
+ lib = koffi.load(dllPath);
42
+ console.log(` ✓ Loaded ${dllPath}`);
43
+ } catch (err: any) {
44
+ console.error(" ✗ Failed to load DLL:", err.message);
45
+ process.exit(1);
46
+ }
47
+
48
+ // 3. Bind functions
49
+ console.log("[3/5] Binding curl functions...");
50
+ const CURL = koffi.pointer("CURL", koffi.opaque());
51
+ const CURLM = koffi.pointer("CURLM", koffi.opaque());
52
+ koffi.pointer("curl_slist", koffi.opaque());
53
+
54
+ const writeCbType = koffi.proto("size_t write_cb(const uint8_t *ptr, size_t size, size_t nmemb, intptr_t userdata)");
55
+
56
+ const fns = {
57
+ curl_global_init: lib.func("int curl_global_init(int flags)"),
58
+ curl_easy_init: lib.func("CURL *curl_easy_init()"),
59
+ curl_easy_cleanup: lib.func("void curl_easy_cleanup(CURL *handle)"),
60
+ curl_easy_setopt_long: lib.func("int curl_easy_setopt(CURL *handle, int option, long value)"),
61
+ curl_easy_setopt_str: lib.func("int curl_easy_setopt(CURL *handle, int option, const char *value)"),
62
+ curl_easy_setopt_cb: lib.func("int curl_easy_setopt(CURL *handle, int option, write_cb *value)"),
63
+ curl_easy_getinfo_long: lib.func("int curl_easy_getinfo(CURL *handle, int info, _Out_ int *value)"),
64
+ curl_easy_impersonate: lib.func("int curl_easy_impersonate(CURL *handle, const char *target, int default_headers)"),
65
+ curl_easy_perform: lib.func("int curl_easy_perform(CURL *handle)"),
66
+ curl_multi_init: lib.func("CURLM *curl_multi_init()"),
67
+ curl_multi_add_handle: lib.func("int curl_multi_add_handle(CURLM *multi, CURL *easy)"),
68
+ curl_multi_remove_handle: lib.func("int curl_multi_remove_handle(CURLM *multi, CURL *easy)"),
69
+ curl_multi_perform: lib.func("int curl_multi_perform(CURLM *multi, _Out_ int *running_handles)"),
70
+ curl_multi_poll: lib.func("int curl_multi_poll(CURLM *multi, void *extra_fds, int extra_nfds, int timeout_ms, _Out_ int *numfds)"),
71
+ curl_multi_cleanup: lib.func("int curl_multi_cleanup(CURLM *multi)"),
72
+ curl_slist_append: lib.func("curl_slist *curl_slist_append(curl_slist *list, const char *string)"),
73
+ curl_slist_free_all: lib.func("void curl_slist_free_all(curl_slist *list)"),
74
+ curl_easy_setopt_ptr: lib.func("int curl_easy_setopt(CURL *handle, int option, curl_slist *value)"),
75
+ };
76
+
77
+ // Constants
78
+ const CURLOPT_URL = 10002;
79
+ const CURLOPT_HTTPHEADER = 10023;
80
+ const CURLOPT_WRITEFUNCTION = 20011;
81
+ const CURLOPT_NOSIGNAL = 99;
82
+ const CURLOPT_ACCEPT_ENCODING = 10102;
83
+ const CURLOPT_HTTP_VERSION = 84;
84
+ const CURL_HTTP_VERSION_2_0 = 3;
85
+ const CURLOPT_CAINFO = 10065;
86
+ const CURLINFO_RESPONSE_CODE = 0x200002;
87
+
88
+ fns.curl_global_init(3); // CURL_GLOBAL_DEFAULT
89
+ console.log(" ✓ Functions bound, curl_global_init OK");
90
+
91
+ // 4. Simple GET to tls.peet.ws using curl_easy_perform.async()
92
+ console.log("[4/5] Testing simple GET with Chrome TLS fingerprint...");
93
+
94
+ const easy = fns.curl_easy_init();
95
+ if (!easy) {
96
+ console.error(" ✗ curl_easy_init returned null");
97
+ process.exit(1);
98
+ }
99
+
100
+ // Impersonate Chrome 136
101
+ const impResult = fns.curl_easy_impersonate(easy, "chrome136", 0);
102
+ console.log(` curl_easy_impersonate result: ${impResult} (0 = OK)`);
103
+
104
+ fns.curl_easy_setopt_str(easy, CURLOPT_URL, "https://tls.peet.ws/api/all");
105
+ fns.curl_easy_setopt_long(easy, CURLOPT_NOSIGNAL, 1);
106
+ fns.curl_easy_setopt_long(easy, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2_0);
107
+ fns.curl_easy_setopt_str(easy, CURLOPT_ACCEPT_ENCODING, "");
108
+
109
+ // CA bundle for BoringSSL (not Schannel)
110
+ const caPath = resolve(BIN_DIR, "cacert.pem");
111
+ if (existsSync(caPath)) {
112
+ fns.curl_easy_setopt_str(easy, CURLOPT_CAINFO, caPath);
113
+ console.log(` Using CA bundle: ${caPath}`);
114
+ }
115
+
116
+ // Set User-Agent to match Chrome
117
+ let slist: any = null;
118
+ slist = fns.curl_slist_append(slist, "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36");
119
+ slist = fns.curl_slist_append(slist, "Expect:");
120
+ fns.curl_easy_setopt_ptr(easy, CURLOPT_HTTPHEADER, slist);
121
+
122
+ const chunks: Buffer[] = [];
123
+ const writeCallback = koffi.register(
124
+ (ptr: any, size: number, nmemb: number, _userdata: any): number => {
125
+ const totalBytes = size * nmemb;
126
+ if (totalBytes === 0) return 0;
127
+ const arr = koffi.decode(ptr, "uint8_t", totalBytes);
128
+ chunks.push(Buffer.from(arr));
129
+ return totalBytes;
130
+ },
131
+ koffi.pointer(writeCbType as Parameters<typeof koffi.pointer>[0]),
132
+ );
133
+ fns.curl_easy_setopt_cb(easy, CURLOPT_WRITEFUNCTION, writeCallback);
134
+
135
+ console.log(" Sending request to https://tls.peet.ws/api/all ...");
136
+ const startTime = Date.now();
137
+
138
+ // Use .async() to run on worker thread (non-blocking)
139
+ // koffi .async() requires callback as last arg — wrap in promise
140
+ const performResult = await new Promise<number>((res, rej) => {
141
+ fns.curl_easy_perform.async(easy, (err: any, result: number) => {
142
+ if (err) rej(err); else res(result);
143
+ });
144
+ });
145
+
146
+ const elapsed = Date.now() - startTime;
147
+ console.log(` curl_easy_perform result: ${performResult} (0 = OK), took ${elapsed}ms`);
148
+
149
+ const statusBuf = new Int32Array(1);
150
+ fns.curl_easy_getinfo_long(easy, CURLINFO_RESPONSE_CODE, statusBuf);
151
+ console.log(` HTTP status: ${statusBuf[0]}`);
152
+
153
+ fns.curl_easy_cleanup(easy);
154
+ if (slist) fns.curl_slist_free_all(slist);
155
+ koffi.unregister(writeCallback);
156
+
157
+ if (performResult !== 0 || statusBuf[0] !== 200) {
158
+ console.error(" ✗ Request failed");
159
+ process.exit(1);
160
+ }
161
+
162
+ const body = Buffer.concat(chunks).toString("utf-8");
163
+ let tlsData: any;
164
+ try {
165
+ tlsData = JSON.parse(body);
166
+ } catch {
167
+ console.error(" ✗ Invalid JSON response:", body.slice(0, 200));
168
+ process.exit(1);
169
+ }
170
+
171
+ console.log("\n === TLS Fingerprint Results ===");
172
+ console.log(` IP: ${tlsData.ip ?? "?"}`);
173
+ console.log(` HTTP/2: ${tlsData.http_version ?? "?"}`);
174
+ console.log(` TLS: ${tlsData.tls?.version ?? "?"}`);
175
+ console.log(` JA3: ${tlsData.tls?.ja3 ?? "N/A"}`);
176
+ console.log(` JA3 Hash: ${tlsData.tls?.ja3_hash ?? "N/A"}`);
177
+ console.log(` JA4: ${tlsData.tls?.ja4 ?? "N/A"}`);
178
+ console.log(` Peetprint: ${tlsData.tls?.peetprint_hash ?? "N/A"}`);
179
+ console.log(` Akamai H2: ${tlsData.http2?.akamai_fingerprint_hash ?? "N/A"}`);
180
+
181
+ // Check if it looks like Chrome
182
+ const ja4 = tlsData.tls?.ja4 ?? "";
183
+ const httpVersion = tlsData.http_version ?? "";
184
+ const isChromeLike = ja4.startsWith("t") && httpVersion === "h2";
185
+ console.log(`\n Chrome-like: ${isChromeLike ? "✓ YES" : "✗ NO"}`);
186
+
187
+ // 5. Test curl_multi async (non-blocking) — verify event loop stays free
188
+ console.log("\n[5/5] Testing curl_multi async (non-blocking)...");
189
+
190
+ const easy2 = fns.curl_easy_init();
191
+ fns.curl_easy_impersonate(easy2, "chrome136", 0);
192
+ fns.curl_easy_setopt_str(easy2, CURLOPT_URL, "https://httpbin.org/get");
193
+ fns.curl_easy_setopt_long(easy2, CURLOPT_NOSIGNAL, 1);
194
+ fns.curl_easy_setopt_str(easy2, CURLOPT_ACCEPT_ENCODING, "");
195
+ if (existsSync(caPath)) {
196
+ fns.curl_easy_setopt_str(easy2, CURLOPT_CAINFO, caPath);
197
+ }
198
+
199
+ const chunks2: Buffer[] = [];
200
+ const writeCallback2 = koffi.register(
201
+ (ptr: any, size: number, nmemb: number, _userdata: any): number => {
202
+ const totalBytes = size * nmemb;
203
+ if (totalBytes === 0) return 0;
204
+ const arr = koffi.decode(ptr, "uint8_t", totalBytes);
205
+ chunks2.push(Buffer.from(arr));
206
+ return totalBytes;
207
+ },
208
+ koffi.pointer(writeCbType as Parameters<typeof koffi.pointer>[0]),
209
+ );
210
+ fns.curl_easy_setopt_cb(easy2, CURLOPT_WRITEFUNCTION, writeCallback2);
211
+
212
+ const multi = fns.curl_multi_init();
213
+ fns.curl_multi_add_handle(multi, easy2);
214
+
215
+ const runningHandles = new Int32Array(1);
216
+ const numfds = new Int32Array(1);
217
+
218
+ // Track event loop freedom
219
+ let timerFired = false;
220
+ const timer = setTimeout(() => { timerFired = true; }, 50);
221
+
222
+ const asyncPoll = (m: any, nfds: Int32Array) =>
223
+ new Promise<number>((res, rej) => {
224
+ fns.curl_multi_poll.async(m, null, 0, 200, nfds, (err: any, r: number) => {
225
+ if (err) rej(err); else res(r);
226
+ });
227
+ });
228
+ const asyncPerform = (m: any, rh: Int32Array) =>
229
+ new Promise<number>((res, rej) => {
230
+ fns.curl_multi_perform.async(m, rh, (err: any, r: number) => {
231
+ if (err) rej(err); else res(r);
232
+ });
233
+ });
234
+
235
+ const multiStart = Date.now();
236
+ let iterations = 0;
237
+ while (true) {
238
+ const pollResult = await asyncPoll(multi, numfds);
239
+ if (pollResult !== 0) break;
240
+
241
+ const perfResult = await asyncPerform(multi, runningHandles);
242
+ if (perfResult !== 0) break;
243
+
244
+ iterations++;
245
+ if (runningHandles[0] === 0) break;
246
+ }
247
+ const multiElapsed = Date.now() - multiStart;
248
+ clearTimeout(timer);
249
+
250
+ fns.curl_multi_remove_handle(multi, easy2);
251
+ fns.curl_multi_cleanup(multi);
252
+ fns.curl_easy_cleanup(easy2);
253
+ koffi.unregister(writeCallback2);
254
+
255
+ const body2 = Buffer.concat(chunks2).toString("utf-8");
256
+ const multiOk = body2.length > 0;
257
+
258
+ console.log(` curl_multi iterations: ${iterations}`);
259
+ console.log(` Response size: ${body2.length} bytes`);
260
+ console.log(` Time: ${multiElapsed}ms`);
261
+ console.log(` Event loop free during transfer: ${timerFired ? "✓ YES" : "✗ NO (blocked)"}`);
262
+ console.log(` curl_multi result: ${multiOk ? "✓ OK" : "✗ FAILED"}`);
263
+
264
+ // Summary
265
+ console.log("\n=== POC Summary ===");
266
+ console.log(` koffi load: ✓`);
267
+ console.log(` DLL load: ✓`);
268
+ console.log(` impersonate: ${impResult === 0 ? "✓" : "✗"}`);
269
+ console.log(` simple GET: ✓ (${elapsed}ms)`);
270
+ console.log(` Chrome fingerprint: ${isChromeLike ? "✓" : "✗"}`);
271
+ console.log(` curl_multi async: ${multiOk ? "✓" : "✗"} (${multiElapsed}ms)`);
272
+ console.log(` event loop free: ${timerFired ? "✓" : "✗"}`);
273
+
274
+ const allPassed = impResult === 0 && isChromeLike && multiOk && timerFired;
275
+ console.log(`\n Overall: ${allPassed ? "ALL PASSED ✓" : "SOME FAILED ✗"}`);
276
+ process.exit(allPassed ? 0 : 1);
277
+ }
278
+
279
+ main().catch((err) => {
280
+ console.error("Fatal:", err);
281
+ process.exit(1);
282
+ });
scripts/setup-curl.ts CHANGED
@@ -10,12 +10,13 @@
10
  */
11
 
12
  import { execSync } from "child_process";
13
- import { existsSync, mkdirSync, chmodSync, readdirSync, copyFileSync, rmSync } from "fs";
14
  import { resolve, join } from "path";
15
 
16
  const REPO = "lexiforest/curl-impersonate";
17
  const FALLBACK_VERSION = "v1.4.4";
18
  const BIN_DIR = resolve(process.cwd(), "bin");
 
19
 
20
  interface PlatformInfo {
21
  /** Pattern to match the asset name in GitHub Releases */
@@ -49,11 +50,12 @@ function getPlatformInfo(version: string): PlatformInfo {
49
  }
50
 
51
  if (platform === "win32") {
52
- throw new Error(
53
- "curl-impersonate CLI binary is not available for Windows.\n" +
54
- "The proxy will fall back to system curl.\n" +
55
- "For full TLS fingerprint matching, run the proxy on Linux or macOS.",
56
- );
 
57
  }
58
 
59
  throw new Error(`Unsupported platform: ${platform}-${arch}`);
@@ -88,12 +90,14 @@ async function getDownloadUrl(info: PlatformInfo, version: string): Promise<stri
88
  const asset = release.assets.find((a) => info.assetPattern.test(a.name));
89
 
90
  if (!asset) {
91
- const cliAssets = release.assets
92
- .filter((a) => a.name.startsWith("curl-impersonate-") && !a.name.startsWith("libcurl"))
 
 
93
  .map((a) => a.name)
94
  .join("\n ");
95
  throw new Error(
96
- `No matching asset for pattern ${info.assetPattern}.\nAvailable CLI assets:\n ${cliAssets}`,
97
  );
98
  }
99
 
@@ -118,7 +122,14 @@ function downloadAndExtract(url: string, info: PlatformInfo): void {
118
  execSync(`curl -L -o "${archivePath}" "${url}"`, { stdio: "inherit" });
119
 
120
  console.log(`[setup] Extracting...`);
121
- execSync(`tar xzf "${archivePath}" -C "${tmpDir}"`, { stdio: "inherit" });
 
 
 
 
 
 
 
122
 
123
  // Find the binary in extracted files (may be in a subdirectory)
124
  const binary = findFile(tmpDir, info.binaryName);
@@ -132,14 +143,18 @@ function downloadAndExtract(url: string, info: PlatformInfo): void {
132
  const destPath = resolve(BIN_DIR, info.destName);
133
  copyFileSync(binary, destPath);
134
 
135
- // Also copy shared libraries (.so/.dylib) if present alongside the binary
136
  const libDir = resolve(binary, "..");
137
  if (existsSync(libDir)) {
138
  const libs = readdirSync(libDir).filter(
139
- (f) => f.endsWith(".so") || f.includes(".so.") || f.endsWith(".dylib"),
 
 
 
140
  );
141
  for (const lib of libs) {
142
  copyFileSync(resolve(libDir, lib), resolve(BIN_DIR, lib));
 
143
  }
144
  }
145
 
@@ -178,6 +193,33 @@ function listFilesRecursive(dir: string): string[] {
178
  return results;
179
  }
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  async function main() {
182
  const checkOnly = process.argv.includes("--check");
183
  const force = process.argv.includes("--force");
@@ -187,26 +229,23 @@ async function main() {
187
  console.log(`[setup] curl-impersonate setup (${version})`);
188
  console.log(`[setup] Platform: ${process.platform}-${process.arch}`);
189
 
190
- if (process.platform === "win32") {
191
- console.warn(
192
- "[setup] curl-impersonate CLI binary is not available for Windows.\n" +
193
- "[setup] The proxy will use system curl. For full TLS fingerprint matching,\n" +
194
- "[setup] deploy on Linux or macOS.",
195
- );
196
- return;
197
- }
198
-
199
- const destBinary = resolve(BIN_DIR, "curl-impersonate");
200
 
201
  if (checkOnly) {
202
  if (existsSync(destBinary)) {
203
- try {
204
- const ver = execSync(`"${destBinary}" --version`, { encoding: "utf-8" }).trim().split("\n")[0];
205
- console.log(`[setup] Current: ${ver}`);
206
- console.log(`[setup] Latest: ${version}`);
207
- } catch {
208
- console.log(`[setup] Binary exists but version check failed`);
 
 
 
209
  }
 
210
  } else {
211
  console.log(`[setup] Not installed. Latest: ${version}`);
212
  }
@@ -223,16 +262,21 @@ async function main() {
223
  console.log(`[setup] Removed existing binary for forced re-download.`);
224
  }
225
 
226
- const info = getPlatformInfo(version);
227
  const url = await getDownloadUrl(info, version);
228
  downloadAndExtract(url, info);
229
 
230
- // Verify the binary runs
231
- try {
232
- const ver = execSync(`"${destBinary}" --version`, { encoding: "utf-8" }).trim().split("\n")[0];
233
- console.log(`[setup] Verified: ${ver}`);
234
- } catch {
235
- console.warn(`[setup] Warning: could not verify binary. It may need shared libraries.`);
 
 
 
 
 
 
236
  }
237
 
238
  console.log(`[setup] Done! curl-impersonate is ready.`);
 
10
  */
11
 
12
  import { execSync } from "child_process";
13
+ import { existsSync, mkdirSync, chmodSync, readdirSync, copyFileSync, rmSync, writeFileSync } from "fs";
14
  import { resolve, join } from "path";
15
 
16
  const REPO = "lexiforest/curl-impersonate";
17
  const FALLBACK_VERSION = "v1.4.4";
18
  const BIN_DIR = resolve(process.cwd(), "bin");
19
+ const CACERT_URL = "https://curl.se/ca/cacert.pem";
20
 
21
  interface PlatformInfo {
22
  /** Pattern to match the asset name in GitHub Releases */
 
50
  }
51
 
52
  if (platform === "win32") {
53
+ // Windows: download libcurl-impersonate package (DLL named libcurl.dll)
54
+ return {
55
+ assetPattern: /libcurl-impersonate-.*\.x86_64-win32\.tar\.gz/,
56
+ binaryName: "libcurl.dll",
57
+ destName: "libcurl.dll",
58
+ };
59
  }
60
 
61
  throw new Error(`Unsupported platform: ${platform}-${arch}`);
 
90
  const asset = release.assets.find((a) => info.assetPattern.test(a.name));
91
 
92
  if (!asset) {
93
+ const relevantAssets = release.assets
94
+ .filter((a) =>
95
+ a.name.startsWith("curl-impersonate-") || a.name.startsWith("libcurl-impersonate-"),
96
+ )
97
  .map((a) => a.name)
98
  .join("\n ");
99
  throw new Error(
100
+ `No matching asset for pattern ${info.assetPattern}.\nAvailable assets:\n ${relevantAssets}`,
101
  );
102
  }
103
 
 
122
  execSync(`curl -L -o "${archivePath}" "${url}"`, { stdio: "inherit" });
123
 
124
  console.log(`[setup] Extracting...`);
125
+ // Windows: tar interprets D: as remote host; --force-local + forward slashes fix this
126
+ if (process.platform === "win32") {
127
+ const tarArchive = archivePath.replaceAll("\\", "/");
128
+ const tarDest = tmpDir.replaceAll("\\", "/");
129
+ execSync(`tar xzf "${tarArchive}" --force-local -C "${tarDest}"`, { stdio: "inherit" });
130
+ } else {
131
+ execSync(`tar xzf "${archivePath}" -C "${tmpDir}"`, { stdio: "inherit" });
132
+ }
133
 
134
  // Find the binary in extracted files (may be in a subdirectory)
135
  const binary = findFile(tmpDir, info.binaryName);
 
143
  const destPath = resolve(BIN_DIR, info.destName);
144
  copyFileSync(binary, destPath);
145
 
146
+ // Also copy shared libraries (.so/.dylib/.dll) if present alongside the binary
147
  const libDir = resolve(binary, "..");
148
  if (existsSync(libDir)) {
149
  const libs = readdirSync(libDir).filter(
150
+ (f) =>
151
+ f.endsWith(".so") || f.includes(".so.") ||
152
+ f.endsWith(".dylib") ||
153
+ (f.endsWith(".dll") && f !== info.destName),
154
  );
155
  for (const lib of libs) {
156
  copyFileSync(resolve(libDir, lib), resolve(BIN_DIR, lib));
157
+ console.log(`[setup] Copied companion library: ${lib}`);
158
  }
159
  }
160
 
 
193
  return results;
194
  }
195
 
196
+ /**
197
+ * Download Mozilla CA certificate bundle for BoringSSL (used on Windows).
198
+ * libcurl-impersonate uses BoringSSL which doesn't read the Windows cert store,
199
+ * so we need an explicit CA bundle.
200
+ */
201
+ async function downloadCaCert(force: boolean): Promise<void> {
202
+ const caPath = resolve(BIN_DIR, "cacert.pem");
203
+ if (existsSync(caPath) && !force) {
204
+ console.log(`[setup] cacert.pem already exists`);
205
+ return;
206
+ }
207
+
208
+ console.log(`[setup] Downloading CA bundle from ${CACERT_URL}...`);
209
+ const resp = await fetch(CACERT_URL);
210
+ if (!resp.ok) {
211
+ console.warn(`[setup] Warning: could not download CA bundle (${resp.status}). HTTPS may fail.`);
212
+ return;
213
+ }
214
+
215
+ const content = await resp.text();
216
+ if (!existsSync(BIN_DIR)) {
217
+ mkdirSync(BIN_DIR, { recursive: true });
218
+ }
219
+ writeFileSync(caPath, content, "utf-8");
220
+ console.log(`[setup] Installed CA bundle to ${caPath}`);
221
+ }
222
+
223
  async function main() {
224
  const checkOnly = process.argv.includes("--check");
225
  const force = process.argv.includes("--force");
 
229
  console.log(`[setup] curl-impersonate setup (${version})`);
230
  console.log(`[setup] Platform: ${process.platform}-${process.arch}`);
231
 
232
+ const info = getPlatformInfo(version);
233
+ const isWindowsDll = process.platform === "win32";
234
+ const destBinary = resolve(BIN_DIR, info.destName);
 
 
 
 
 
 
 
235
 
236
  if (checkOnly) {
237
  if (existsSync(destBinary)) {
238
+ if (isWindowsDll) {
239
+ console.log(`[setup] ${info.destName} exists`);
240
+ } else {
241
+ try {
242
+ const ver = execSync(`"${destBinary}" --version`, { encoding: "utf-8" }).trim().split("\n")[0];
243
+ console.log(`[setup] Current: ${ver}`);
244
+ } catch {
245
+ console.log(`[setup] Binary exists but version check failed`);
246
+ }
247
  }
248
+ console.log(`[setup] Latest: ${version}`);
249
  } else {
250
  console.log(`[setup] Not installed. Latest: ${version}`);
251
  }
 
262
  console.log(`[setup] Removed existing binary for forced re-download.`);
263
  }
264
 
 
265
  const url = await getDownloadUrl(info, version);
266
  downloadAndExtract(url, info);
267
 
268
+ // Verify the binary runs (skip for Windows DLL — no CLI to test)
269
+ if (!isWindowsDll) {
270
+ try {
271
+ const ver = execSync(`"${destBinary}" --version`, { encoding: "utf-8" }).trim().split("\n")[0];
272
+ console.log(`[setup] Verified: ${ver}`);
273
+ } catch {
274
+ console.warn(`[setup] Warning: could not verify binary. It may need shared libraries.`);
275
+ }
276
+ } else {
277
+ console.log(`[setup] Installed libcurl-impersonate DLL for FFI transport`);
278
+ // BoringSSL needs a CA bundle — download it
279
+ await downloadCaCert(force);
280
  }
281
 
282
  console.log(`[setup] Done! curl-impersonate is ready.`);
src/config.ts CHANGED
@@ -47,6 +47,7 @@ const ConfigSchema = z.object({
47
  curl_binary: z.string().default("auto"),
48
  impersonate_profile: z.string().default("chrome136"),
49
  proxy_url: z.string().nullable().default(null),
 
50
  }).default({}),
51
  streaming: z.object({
52
  status_as_content: z.boolean().default(false),
 
47
  curl_binary: z.string().default("auto"),
48
  impersonate_profile: z.string().default("chrome136"),
49
  proxy_url: z.string().nullable().default(null),
50
+ transport: z.enum(["auto", "curl-cli", "libcurl-ffi"]).default("auto"),
51
  }).default({}),
52
  streaming: z.object({
53
  status_as_content: z.boolean().default(false),
src/index.ts CHANGED
@@ -16,6 +16,7 @@ import { createWebRoutes } from "./routes/web.js";
16
  import { CookieJar } from "./proxy/cookie-jar.js";
17
  import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
18
  import { initProxy } from "./tls/curl-binary.js";
 
19
 
20
  async function main() {
21
  // Load configuration
@@ -34,6 +35,9 @@ async function main() {
34
  // Detect proxy (config > env > auto-detect local ports)
35
  await initProxy();
36
 
 
 
 
37
  // Initialize managers
38
  const accountPool = new AccountPool();
39
  const refreshScheduler = new RefreshScheduler(accountPool);
 
16
  import { CookieJar } from "./proxy/cookie-jar.js";
17
  import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
18
  import { initProxy } from "./tls/curl-binary.js";
19
+ import { initTransport } from "./tls/transport.js";
20
 
21
  async function main() {
22
  // Load configuration
 
35
  // Detect proxy (config > env > auto-detect local ports)
36
  await initProxy();
37
 
38
+ // Initialize TLS transport (auto-selects curl CLI or libcurl FFI)
39
+ await initTransport();
40
+
41
  // Initialize managers
42
  const accountPool = new AccountPool();
43
  const refreshScheduler = new RefreshScheduler(accountPool);
src/proxy/codex-api.ts CHANGED
@@ -5,13 +5,12 @@
5
  * This is the API the Codex CLI actually uses.
6
  * It requires: instructions, store: false, stream: true.
7
  *
8
- * Both GET and POST requests use curl subprocess to avoid
9
- * Cloudflare TLS fingerprinting of Node.js/undici.
10
  */
11
 
12
- import { spawn, execFile } from "child_process";
13
  import { getConfig } from "../config.js";
14
- import { resolveCurlBinary, getChromeTlsArgs, getProxyArgs, isImpersonate } from "../tls/curl-binary.js";
15
  import {
16
  buildHeaders,
17
  buildHeadersWithContentType,
@@ -43,13 +42,6 @@ export interface CodexSSEEvent {
43
  data: unknown;
44
  }
45
 
46
- interface CurlResponse {
47
- status: number;
48
- headers: Headers;
49
- body: ReadableStream<Uint8Array>;
50
- setCookieHeaders: string[];
51
- }
52
-
53
  export class CodexApi {
54
  private token: string;
55
  private accountId: string | null;
@@ -81,195 +73,40 @@ export class CodexApi {
81
  return headers;
82
  }
83
 
84
- /** Capture Set-Cookie headers from curl response into the jar. */
85
- private captureCookiesFromCurl(setCookieHeaders: string[]): void {
86
  if (this.cookieJar && this.entryId && setCookieHeaders.length > 0) {
87
  this.cookieJar.captureRaw(this.entryId, setCookieHeaders);
88
  }
89
  }
90
 
91
- /**
92
- * Execute a POST request via curl subprocess.
93
- * Returns headers + streaming body as a CurlResponse.
94
- */
95
- private curlPost(
96
- url: string,
97
- headers: Record<string, string>,
98
- body: string,
99
- signal?: AbortSignal,
100
- timeoutSec?: number,
101
- ): Promise<CurlResponse> {
102
- return new Promise((resolve, reject) => {
103
- const args = [
104
- ...getChromeTlsArgs(), // Chrome TLS profile (ciphers, HTTP/2, etc.)
105
- ...getProxyArgs(), // HTTP/SOCKS5 proxy if configured
106
- "-s", "-S", // silent but show errors
107
- "--compressed", // curl negotiates compression
108
- "-N", // no output buffering (SSE)
109
- "-i", // include response headers in stdout
110
- "-X", "POST",
111
- "--data-binary", "@-", // read body from stdin
112
- ];
113
-
114
- if (timeoutSec) {
115
- args.push("--max-time", String(timeoutSec));
116
- }
117
-
118
- // Pass all headers explicitly in our fingerprint order.
119
- // Accept-Encoding is kept so curl doesn't inject its own at position 2.
120
- // --compressed still handles auto-decompression of the response.
121
- for (const [key, value] of Object.entries(headers)) {
122
- args.push("-H", `${key}: ${value}`);
123
- }
124
- // Suppress curl's auto Expect: 100-continue (Chromium never sends it)
125
- args.push("-H", "Expect:");
126
- args.push(url);
127
-
128
- const child = spawn(resolveCurlBinary(), args, {
129
- stdio: ["pipe", "pipe", "pipe"],
130
- });
131
-
132
- // Abort handling
133
- const onAbort = () => {
134
- child.kill("SIGTERM");
135
- };
136
- if (signal) {
137
- if (signal.aborted) {
138
- child.kill("SIGTERM");
139
- reject(new Error("Aborted"));
140
- return;
141
- }
142
- signal.addEventListener("abort", onAbort, { once: true });
143
- }
144
-
145
- // Write body to stdin then close
146
- child.stdin.write(body);
147
- child.stdin.end();
148
-
149
- let headerBuf = Buffer.alloc(0);
150
- let headersParsed = false;
151
- let bodyController: ReadableStreamDefaultController<Uint8Array> | null = null;
152
-
153
- // P0-1: Header parse timeout — kill curl if headers aren't received within 30s
154
- const HEADER_TIMEOUT_MS = 30_000;
155
- const headerTimer = setTimeout(() => {
156
- if (!headersParsed) {
157
- child.kill("SIGTERM");
158
- reject(new CodexApiError(0, `curl header parse timeout after ${HEADER_TIMEOUT_MS}ms`));
159
- }
160
- }, HEADER_TIMEOUT_MS);
161
- if (headerTimer.unref) headerTimer.unref();
162
-
163
- const bodyStream = new ReadableStream<Uint8Array>({
164
- start(c) {
165
- bodyController = c;
166
- },
167
- cancel() {
168
- child.kill("SIGTERM");
169
- },
170
- });
171
-
172
- child.stdout.on("data", (chunk: Buffer) => {
173
- if (headersParsed) {
174
- bodyController?.enqueue(new Uint8Array(chunk));
175
- return;
176
- }
177
-
178
- // Accumulate until we find \r\n\r\n header separator
179
- headerBuf = Buffer.concat([headerBuf, chunk]);
180
- const separatorIdx = headerBuf.indexOf("\r\n\r\n");
181
- if (separatorIdx === -1) return;
182
-
183
- headersParsed = true;
184
- clearTimeout(headerTimer);
185
- const headerBlock = headerBuf.subarray(0, separatorIdx).toString("utf-8");
186
- const remaining = headerBuf.subarray(separatorIdx + 4);
187
-
188
- // Parse status and headers
189
- const { status, headers: parsedHeaders, setCookieHeaders } = parseHeaderDump(headerBlock);
190
-
191
- // Push remaining data (body after separator) into stream
192
- if (remaining.length > 0) {
193
- bodyController?.enqueue(new Uint8Array(remaining));
194
- }
195
-
196
- if (signal) {
197
- signal.removeEventListener("abort", onAbort);
198
- }
199
-
200
- resolve({
201
- status,
202
- headers: parsedHeaders,
203
- body: bodyStream,
204
- setCookieHeaders,
205
- });
206
- });
207
-
208
- let stderrBuf = "";
209
- child.stderr.on("data", (chunk: Buffer) => {
210
- stderrBuf += chunk.toString();
211
- });
212
-
213
- child.on("close", (code) => {
214
- clearTimeout(headerTimer);
215
- if (signal) {
216
- signal.removeEventListener("abort", onAbort);
217
- }
218
- if (!headersParsed) {
219
- reject(new CodexApiError(0, `curl exited with code ${code}: ${stderrBuf}`));
220
- }
221
- bodyController?.close();
222
- });
223
-
224
- child.on("error", (err) => {
225
- clearTimeout(headerTimer);
226
- if (signal) {
227
- signal.removeEventListener("abort", onAbort);
228
- }
229
- reject(new CodexApiError(0, `curl spawn error: ${err.message}`));
230
- });
231
- });
232
- }
233
-
234
  /**
235
  * Query official Codex usage/quota.
236
  * GET /backend-api/codex/usage
237
- *
238
- * Uses curl subprocess instead of Node.js fetch because Cloudflare
239
- * fingerprints the TLS handshake and blocks Node.js/undici requests
240
- * with a JS challenge (403). System curl uses native TLS (WinSSL/SecureTransport)
241
- * which Cloudflare accepts.
242
  */
243
  async getUsage(): Promise<CodexUsageResponse> {
244
  const config = getConfig();
 
245
  const url = `${config.api.base_url}/codex/usage`;
246
 
247
  const headers = this.applyHeaders(
248
  buildHeaders(this.token, this.accountId),
249
  );
250
  headers["Accept"] = "application/json";
251
- // When using system curl (not curl-impersonate), downgrade Accept-Encoding
252
- // to encodings it can always decompress. curl-impersonate supports br/zstd.
253
- if (!isImpersonate()) {
254
  headers["Accept-Encoding"] = "gzip, deflate";
255
  }
256
 
257
- // Build curl args (Chrome TLS profile + proxy + request params)
258
- const args = [...getChromeTlsArgs(), ...getProxyArgs(), "-s", "--compressed", "--max-time", "15"];
259
- for (const [key, value] of Object.entries(headers)) {
260
- args.push("-H", `${key}: ${value}`);
 
 
 
261
  }
262
- args.push(url);
263
-
264
- const body = await new Promise<string>((resolve, reject) => {
265
- execFile(resolveCurlBinary(), args, { maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
266
- if (err) {
267
- reject(new CodexApiError(0, `curl failed: ${err.message} ${stderr}`));
268
- } else {
269
- resolve(stdout);
270
- }
271
- });
272
- });
273
 
274
  try {
275
  const parsed = JSON.parse(body) as CodexUsageResponse;
@@ -287,13 +124,13 @@ export class CodexApi {
287
  /**
288
  * Create a response (streaming).
289
  * Returns the raw Response so the caller can process the SSE stream.
290
- * Uses curl subprocess for native TLS fingerprint.
291
  */
292
  async createResponse(
293
  request: CodexResponsesRequest,
294
  signal?: AbortSignal,
295
  ): Promise<Response> {
296
  const config = getConfig();
 
297
  const baseUrl = config.api.base_url;
298
  const url = `${baseUrl}/codex/responses`;
299
 
@@ -304,15 +141,21 @@ export class CodexApi {
304
 
305
  const timeout = config.api.timeout_seconds;
306
 
307
- const curlRes = await this.curlPost(url, headers, JSON.stringify(request), signal, timeout);
 
 
 
 
 
 
308
 
309
  // Capture cookies
310
- this.captureCookiesFromCurl(curlRes.setCookieHeaders);
311
 
312
- if (curlRes.status < 200 || curlRes.status >= 300) {
313
- // Read the body for error details (P0-3: cap at 1MB to prevent memory spikes)
314
  const MAX_ERROR_BODY = 1024 * 1024; // 1MB
315
- const reader = curlRes.body.getReader();
316
  const chunks: Uint8Array[] = [];
317
  let totalSize = 0;
318
  while (true) {
@@ -322,7 +165,6 @@ export class CodexApi {
322
  if (totalSize <= MAX_ERROR_BODY) {
323
  chunks.push(value);
324
  } else {
325
- // Truncate: push only the part that fits
326
  const overshoot = totalSize - MAX_ERROR_BODY;
327
  if (value.byteLength > overshoot) {
328
  chunks.push(value.subarray(0, value.byteLength - overshoot));
@@ -332,12 +174,12 @@ export class CodexApi {
332
  }
333
  }
334
  const errorBody = Buffer.concat(chunks).toString("utf-8");
335
- throw new CodexApiError(curlRes.status, errorBody);
336
  }
337
 
338
- return new Response(curlRes.body, {
339
- status: curlRes.status,
340
- headers: curlRes.headers,
341
  });
342
  }
343
 
@@ -415,38 +257,6 @@ export class CodexApi {
415
  }
416
  }
417
 
418
- /** Parse the HTTP response header block from curl -i output. */
419
- function parseHeaderDump(headerBlock: string): {
420
- status: number;
421
- headers: Headers;
422
- setCookieHeaders: string[];
423
- } {
424
- const lines = headerBlock.split("\r\n");
425
- let status = 0;
426
- const headers = new Headers();
427
- const setCookieHeaders: string[] = [];
428
-
429
- for (let i = 0; i < lines.length; i++) {
430
- const line = lines[i];
431
- if (i === 0) {
432
- // Status line: HTTP/1.1 200 OK
433
- const match = line.match(/^HTTP\/[\d.]+ (\d+)/);
434
- if (match) status = parseInt(match[1], 10);
435
- continue;
436
- }
437
- const colonIdx = line.indexOf(":");
438
- if (colonIdx === -1) continue;
439
- const key = line.slice(0, colonIdx).trim();
440
- const value = line.slice(colonIdx + 1).trim();
441
- if (key.toLowerCase() === "set-cookie") {
442
- setCookieHeaders.push(value);
443
- }
444
- headers.append(key, value);
445
- }
446
-
447
- return { status, headers, setCookieHeaders };
448
- }
449
-
450
  /** Response from GET /backend-api/codex/usage */
451
  export interface CodexUsageRateWindow {
452
  used_percent: number;
 
5
  * This is the API the Codex CLI actually uses.
6
  * It requires: instructions, store: false, stream: true.
7
  *
8
+ * All upstream requests go through the TLS transport layer
9
+ * (curl CLI or libcurl FFI) to avoid Cloudflare TLS fingerprinting.
10
  */
11
 
 
12
  import { getConfig } from "../config.js";
13
+ import { getTransport } from "../tls/transport.js";
14
  import {
15
  buildHeaders,
16
  buildHeadersWithContentType,
 
42
  data: unknown;
43
  }
44
 
 
 
 
 
 
 
 
45
  export class CodexApi {
46
  private token: string;
47
  private accountId: string | null;
 
73
  return headers;
74
  }
75
 
76
+ /** Capture Set-Cookie headers from transport response into the jar. */
77
+ private captureCookies(setCookieHeaders: string[]): void {
78
  if (this.cookieJar && this.entryId && setCookieHeaders.length > 0) {
79
  this.cookieJar.captureRaw(this.entryId, setCookieHeaders);
80
  }
81
  }
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  /**
84
  * Query official Codex usage/quota.
85
  * GET /backend-api/codex/usage
 
 
 
 
 
86
  */
87
  async getUsage(): Promise<CodexUsageResponse> {
88
  const config = getConfig();
89
+ const transport = getTransport();
90
  const url = `${config.api.base_url}/codex/usage`;
91
 
92
  const headers = this.applyHeaders(
93
  buildHeaders(this.token, this.accountId),
94
  );
95
  headers["Accept"] = "application/json";
96
+ // When transport lacks Chrome TLS fingerprint, downgrade Accept-Encoding
97
+ // to encodings system curl can always decompress.
98
+ if (!transport.isImpersonate()) {
99
  headers["Accept-Encoding"] = "gzip, deflate";
100
  }
101
 
102
+ let body: string;
103
+ try {
104
+ const result = await transport.get(url, headers, 15);
105
+ body = result.body;
106
+ } catch (err) {
107
+ const msg = err instanceof Error ? err.message : String(err);
108
+ throw new CodexApiError(0, `transport GET failed: ${msg}`);
109
  }
 
 
 
 
 
 
 
 
 
 
 
110
 
111
  try {
112
  const parsed = JSON.parse(body) as CodexUsageResponse;
 
124
  /**
125
  * Create a response (streaming).
126
  * Returns the raw Response so the caller can process the SSE stream.
 
127
  */
128
  async createResponse(
129
  request: CodexResponsesRequest,
130
  signal?: AbortSignal,
131
  ): Promise<Response> {
132
  const config = getConfig();
133
+ const transport = getTransport();
134
  const baseUrl = config.api.base_url;
135
  const url = `${baseUrl}/codex/responses`;
136
 
 
141
 
142
  const timeout = config.api.timeout_seconds;
143
 
144
+ let transportRes;
145
+ try {
146
+ transportRes = await transport.post(url, headers, JSON.stringify(request), signal, timeout);
147
+ } catch (err) {
148
+ const msg = err instanceof Error ? err.message : String(err);
149
+ throw new CodexApiError(0, msg);
150
+ }
151
 
152
  // Capture cookies
153
+ this.captureCookies(transportRes.setCookieHeaders);
154
 
155
+ if (transportRes.status < 200 || transportRes.status >= 300) {
156
+ // Read the body for error details (cap at 1MB to prevent memory spikes)
157
  const MAX_ERROR_BODY = 1024 * 1024; // 1MB
158
+ const reader = transportRes.body.getReader();
159
  const chunks: Uint8Array[] = [];
160
  let totalSize = 0;
161
  while (true) {
 
165
  if (totalSize <= MAX_ERROR_BODY) {
166
  chunks.push(value);
167
  } else {
 
168
  const overshoot = totalSize - MAX_ERROR_BODY;
169
  if (value.byteLength > overshoot) {
170
  chunks.push(value.subarray(0, value.byteLength - overshoot));
 
174
  }
175
  }
176
  const errorBody = Buffer.concat(chunks).toString("utf-8");
177
+ throw new CodexApiError(transportRes.status, errorBody);
178
  }
179
 
180
+ return new Response(transportRes.body, {
181
+ status: transportRes.status,
182
+ headers: transportRes.headers,
183
  });
184
  }
185
 
 
257
  }
258
  }
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  /** Response from GET /backend-api/codex/usage */
261
  export interface CodexUsageRateWindow {
262
  used_percent: number;
src/tls/curl-binary.ts CHANGED
@@ -219,6 +219,14 @@ export function isImpersonate(): boolean {
219
  return _isImpersonate;
220
  }
221
 
 
 
 
 
 
 
 
 
222
  /**
223
  * Reset the cached binary path (useful for testing).
224
  */
 
219
  return _isImpersonate;
220
  }
221
 
222
+ /**
223
+ * Get the detected proxy URL (or null if no proxy).
224
+ * Used by LibcurlFfiTransport which needs the URL directly (not CLI args).
225
+ */
226
+ export function getProxyUrl(): string | null {
227
+ return _proxyUrl ?? null;
228
+ }
229
+
230
  /**
231
  * Reset the cached binary path (useful for testing).
232
  */
src/tls/curl-cli-transport.ts ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * CurlCliTransport — TLS transport using curl CLI subprocess.
3
+ *
4
+ * Extracted from codex-api.ts (curlPost) and curl-fetch.ts (execCurl).
5
+ * Supports both streaming POST (SSE) and simple GET/POST.
6
+ *
7
+ * Used on macOS/Linux (curl-impersonate CLI) and as fallback on Windows (system curl).
8
+ */
9
+
10
+ import { spawn, execFile } from "child_process";
11
+ import { resolveCurlBinary, getChromeTlsArgs, getProxyArgs, isImpersonate as curlIsImpersonate } from "./curl-binary.js";
12
+ import type { TlsTransport, TlsTransportResponse } from "./transport.js";
13
+
14
+ const STATUS_SEPARATOR = "\n__CURL_HTTP_STATUS__";
15
+ const HEADER_TIMEOUT_MS = 30_000;
16
+
17
+ export class CurlCliTransport implements TlsTransport {
18
+ /**
19
+ * Streaming POST — spawns curl with -i to capture headers + stream body.
20
+ * Used for SSE requests to Codex Responses API.
21
+ */
22
+ post(
23
+ url: string,
24
+ headers: Record<string, string>,
25
+ body: string,
26
+ signal?: AbortSignal,
27
+ timeoutSec?: number,
28
+ ): Promise<TlsTransportResponse> {
29
+ return new Promise((resolve, reject) => {
30
+ const args = [
31
+ ...getChromeTlsArgs(),
32
+ ...getProxyArgs(),
33
+ "-s", "-S",
34
+ "--compressed",
35
+ "-N", // no output buffering (SSE)
36
+ "-i", // include response headers in stdout
37
+ "-X", "POST",
38
+ "--data-binary", "@-", // read body from stdin
39
+ ];
40
+
41
+ if (timeoutSec) {
42
+ args.push("--max-time", String(timeoutSec));
43
+ }
44
+
45
+ for (const [key, value] of Object.entries(headers)) {
46
+ args.push("-H", `${key}: ${value}`);
47
+ }
48
+ // Suppress curl's auto Expect: 100-continue (Chromium never sends it)
49
+ args.push("-H", "Expect:");
50
+ args.push(url);
51
+
52
+ const child = spawn(resolveCurlBinary(), args, {
53
+ stdio: ["pipe", "pipe", "pipe"],
54
+ });
55
+
56
+ // Abort handling
57
+ const onAbort = () => {
58
+ child.kill("SIGTERM");
59
+ };
60
+ if (signal) {
61
+ if (signal.aborted) {
62
+ child.kill("SIGTERM");
63
+ reject(new Error("Aborted"));
64
+ return;
65
+ }
66
+ signal.addEventListener("abort", onAbort, { once: true });
67
+ }
68
+
69
+ // Write body to stdin then close
70
+ child.stdin.write(body);
71
+ child.stdin.end();
72
+
73
+ let headerBuf = Buffer.alloc(0);
74
+ let headersParsed = false;
75
+ let bodyController: ReadableStreamDefaultController<Uint8Array> | null = null;
76
+
77
+ // Header parse timeout — kill curl if headers aren't received
78
+ const headerTimer = setTimeout(() => {
79
+ if (!headersParsed) {
80
+ child.kill("SIGTERM");
81
+ reject(new Error(`curl header parse timeout after ${HEADER_TIMEOUT_MS}ms`));
82
+ }
83
+ }, HEADER_TIMEOUT_MS);
84
+ if (headerTimer.unref) headerTimer.unref();
85
+
86
+ const bodyStream = new ReadableStream<Uint8Array>({
87
+ start(c) {
88
+ bodyController = c;
89
+ },
90
+ cancel() {
91
+ child.kill("SIGTERM");
92
+ },
93
+ });
94
+
95
+ child.stdout.on("data", (chunk: Buffer) => {
96
+ if (headersParsed) {
97
+ bodyController?.enqueue(new Uint8Array(chunk));
98
+ return;
99
+ }
100
+
101
+ // Accumulate until we find \r\n\r\n header separator
102
+ headerBuf = Buffer.concat([headerBuf, chunk]);
103
+ const separatorIdx = headerBuf.indexOf("\r\n\r\n");
104
+ if (separatorIdx === -1) return;
105
+
106
+ headersParsed = true;
107
+ clearTimeout(headerTimer);
108
+ const headerBlock = headerBuf.subarray(0, separatorIdx).toString("utf-8");
109
+ const remaining = headerBuf.subarray(separatorIdx + 4);
110
+
111
+ const { status, headers: parsedHeaders, setCookieHeaders } = parseHeaderDump(headerBlock);
112
+
113
+ if (remaining.length > 0) {
114
+ bodyController?.enqueue(new Uint8Array(remaining));
115
+ }
116
+
117
+ if (signal) {
118
+ signal.removeEventListener("abort", onAbort);
119
+ }
120
+
121
+ resolve({
122
+ status,
123
+ headers: parsedHeaders,
124
+ body: bodyStream,
125
+ setCookieHeaders,
126
+ });
127
+ });
128
+
129
+ let stderrBuf = "";
130
+ child.stderr.on("data", (chunk: Buffer) => {
131
+ stderrBuf += chunk.toString();
132
+ });
133
+
134
+ child.on("close", (code) => {
135
+ clearTimeout(headerTimer);
136
+ if (signal) {
137
+ signal.removeEventListener("abort", onAbort);
138
+ }
139
+ if (!headersParsed) {
140
+ reject(new Error(`curl exited with code ${code}: ${stderrBuf}`));
141
+ }
142
+ bodyController?.close();
143
+ });
144
+
145
+ child.on("error", (err) => {
146
+ clearTimeout(headerTimer);
147
+ if (signal) {
148
+ signal.removeEventListener("abort", onAbort);
149
+ }
150
+ reject(new Error(`curl spawn error: ${err.message}`));
151
+ });
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Simple GET — execFile curl, returns full body as string.
157
+ */
158
+ get(
159
+ url: string,
160
+ headers: Record<string, string>,
161
+ timeoutSec = 30,
162
+ ): Promise<{ status: number; body: string }> {
163
+ const args = [
164
+ ...getChromeTlsArgs(),
165
+ ...getProxyArgs(),
166
+ "-s", "-S",
167
+ "--compressed",
168
+ "--max-time", String(timeoutSec),
169
+ ];
170
+
171
+ for (const [key, value] of Object.entries(headers)) {
172
+ args.push("-H", `${key}: ${value}`);
173
+ }
174
+ args.push("-H", "Expect:");
175
+ args.push("-w", STATUS_SEPARATOR + "%{http_code}");
176
+ args.push(url);
177
+
178
+ return execCurl(args);
179
+ }
180
+
181
+ /**
182
+ * Simple (non-streaming) POST — execFile curl, returns full body as string.
183
+ * Used for OAuth token exchange, device code requests, etc.
184
+ */
185
+ simplePost(
186
+ url: string,
187
+ headers: Record<string, string>,
188
+ body: string,
189
+ timeoutSec = 30,
190
+ ): Promise<{ status: number; body: string }> {
191
+ const args = [
192
+ ...getChromeTlsArgs(),
193
+ ...getProxyArgs(),
194
+ "-s", "-S",
195
+ "--compressed",
196
+ "--max-time", String(timeoutSec),
197
+ "-X", "POST",
198
+ ];
199
+
200
+ for (const [key, value] of Object.entries(headers)) {
201
+ args.push("-H", `${key}: ${value}`);
202
+ }
203
+ args.push("-H", "Expect:");
204
+ args.push("-d", body);
205
+ args.push("-w", STATUS_SEPARATOR + "%{http_code}");
206
+ args.push(url);
207
+
208
+ return execCurl(args);
209
+ }
210
+
211
+ isImpersonate(): boolean {
212
+ return curlIsImpersonate();
213
+ }
214
+ }
215
+
216
+ /** Execute curl via execFile and parse the status code from the output. */
217
+ function execCurl(args: string[]): Promise<{ status: number; body: string }> {
218
+ return new Promise((resolve, reject) => {
219
+ execFile(
220
+ resolveCurlBinary(),
221
+ args,
222
+ { maxBuffer: 2 * 1024 * 1024 },
223
+ (err, stdout, stderr) => {
224
+ if (err) {
225
+ reject(new Error(`curl failed: ${err.message} ${stderr}`));
226
+ return;
227
+ }
228
+
229
+ const sepIdx = stdout.lastIndexOf(STATUS_SEPARATOR);
230
+ if (sepIdx === -1) {
231
+ reject(new Error("curl: missing status separator in output"));
232
+ return;
233
+ }
234
+
235
+ const body = stdout.slice(0, sepIdx);
236
+ const status = parseInt(stdout.slice(sepIdx + STATUS_SEPARATOR.length), 10);
237
+
238
+ resolve({ status, body });
239
+ },
240
+ );
241
+ });
242
+ }
243
+
244
+ /** Parse HTTP response header block from curl -i output. */
245
+ function parseHeaderDump(headerBlock: string): {
246
+ status: number;
247
+ headers: Headers;
248
+ setCookieHeaders: string[];
249
+ } {
250
+ const lines = headerBlock.split("\r\n");
251
+ let status = 0;
252
+ const headers = new Headers();
253
+ const setCookieHeaders: string[] = [];
254
+
255
+ for (let i = 0; i < lines.length; i++) {
256
+ const line = lines[i];
257
+ if (i === 0) {
258
+ const match = line.match(/^HTTP\/[\d.]+ (\d+)/);
259
+ if (match) status = parseInt(match[1], 10);
260
+ continue;
261
+ }
262
+ const colonIdx = line.indexOf(":");
263
+ if (colonIdx === -1) continue;
264
+ const key = line.slice(0, colonIdx).trim();
265
+ const value = line.slice(colonIdx + 1).trim();
266
+ if (key.toLowerCase() === "set-cookie") {
267
+ setCookieHeaders.push(value);
268
+ }
269
+ headers.append(key, value);
270
+ }
271
+
272
+ return { status, headers, setCookieHeaders };
273
+ }
src/tls/curl-fetch.ts CHANGED
@@ -1,14 +1,14 @@
1
  /**
2
- * Simple GET/POST helpers using curl-impersonate.
3
  *
4
  * Drop-in replacement for Node.js fetch() that routes through
5
- * curl-impersonate with Chrome TLS profile to avoid fingerprinting.
6
  *
 
7
  * Used for non-streaming requests (OAuth, appcast, etc.).
8
  */
9
 
10
- import { execFile } from "child_process";
11
- import { resolveCurlBinary, getChromeTlsArgs, getProxyArgs } from "./curl-binary.js";
12
  import { buildAnonymousHeaders } from "../fingerprint/manager.js";
13
 
14
  export interface CurlFetchResponse {
@@ -17,96 +17,37 @@ export interface CurlFetchResponse {
17
  ok: boolean;
18
  }
19
 
20
- const STATUS_SEPARATOR = "\n__CURL_HTTP_STATUS__";
21
-
22
  /**
23
- * Perform a GET request via curl-impersonate.
24
  */
25
- export function curlFetchGet(url: string): Promise<CurlFetchResponse> {
26
- const args = [
27
- ...getChromeTlsArgs(),
28
- ...getProxyArgs(),
29
- "-s", "-S",
30
- "--compressed",
31
- "--max-time", "30",
32
- ];
33
-
34
- // Inject fingerprint headers (User-Agent, sec-ch-ua, Accept-Encoding, etc.)
35
- const fpHeaders = buildAnonymousHeaders();
36
- for (const [key, value] of Object.entries(fpHeaders)) {
37
- args.push("-H", `${key}: ${value}`);
38
- }
39
- args.push("-H", "Expect:");
40
-
41
- args.push(
42
- "-w", STATUS_SEPARATOR + "%{http_code}",
43
- url,
44
- );
45
-
46
- return execCurl(args);
47
  }
48
 
49
  /**
50
- * Perform a POST request via curl-impersonate.
51
  */
52
- export function curlFetchPost(
53
  url: string,
54
  contentType: string,
55
  body: string,
56
  ): Promise<CurlFetchResponse> {
57
- const args = [
58
- ...getChromeTlsArgs(),
59
- ...getProxyArgs(),
60
- "-s", "-S",
61
- "--compressed",
62
- "--max-time", "30",
63
- "-X", "POST",
64
- ];
65
-
66
- // Inject fingerprint headers (User-Agent, sec-ch-ua, Accept-Encoding, etc.)
67
- const fpHeaders = buildAnonymousHeaders();
68
- for (const [key, value] of Object.entries(fpHeaders)) {
69
- args.push("-H", `${key}: ${value}`);
70
- }
71
- args.push("-H", `Content-Type: ${contentType}`);
72
- args.push("-H", "Expect:");
73
-
74
- args.push(
75
- "-d", body,
76
- "-w", STATUS_SEPARATOR + "%{http_code}",
77
- url,
78
- );
79
-
80
- return execCurl(args);
81
- }
82
-
83
- function execCurl(args: string[]): Promise<CurlFetchResponse> {
84
- return new Promise((resolve, reject) => {
85
- execFile(
86
- resolveCurlBinary(),
87
- args,
88
- { maxBuffer: 2 * 1024 * 1024 },
89
- (err, stdout, stderr) => {
90
- if (err) {
91
- reject(new Error(`curl failed: ${err.message} ${stderr}`));
92
- return;
93
- }
94
-
95
- const sepIdx = stdout.lastIndexOf(STATUS_SEPARATOR);
96
- if (sepIdx === -1) {
97
- reject(new Error(`curl: missing status separator in output`));
98
- return;
99
- }
100
-
101
- const body = stdout.slice(0, sepIdx);
102
- const status = parseInt(stdout.slice(sepIdx + STATUS_SEPARATOR.length), 10);
103
-
104
- resolve({
105
- status,
106
- body,
107
- ok: status >= 200 && status < 300,
108
- });
109
- },
110
- );
111
- });
112
  }
 
1
  /**
2
+ * Simple GET/POST helpers using the TLS transport layer.
3
  *
4
  * Drop-in replacement for Node.js fetch() that routes through
5
+ * the active transport (curl CLI or libcurl FFI) with Chrome TLS profile.
6
  *
7
+ * Automatically injects anonymous fingerprint headers.
8
  * Used for non-streaming requests (OAuth, appcast, etc.).
9
  */
10
 
11
+ import { getTransport } from "./transport.js";
 
12
  import { buildAnonymousHeaders } from "../fingerprint/manager.js";
13
 
14
  export interface CurlFetchResponse {
 
17
  ok: boolean;
18
  }
19
 
 
 
20
  /**
21
+ * Perform a GET request via the TLS transport.
22
  */
23
+ export async function curlFetchGet(url: string): Promise<CurlFetchResponse> {
24
+ const transport = getTransport();
25
+ const headers = buildAnonymousHeaders();
26
+
27
+ const result = await transport.get(url, headers, 30);
28
+ return {
29
+ status: result.status,
30
+ body: result.body,
31
+ ok: result.status >= 200 && result.status < 300,
32
+ };
 
 
 
 
 
 
 
 
 
 
 
 
33
  }
34
 
35
  /**
36
+ * Perform a POST request via the TLS transport.
37
  */
38
+ export async function curlFetchPost(
39
  url: string,
40
  contentType: string,
41
  body: string,
42
  ): Promise<CurlFetchResponse> {
43
+ const transport = getTransport();
44
+ const headers = buildAnonymousHeaders();
45
+ headers["Content-Type"] = contentType;
46
+
47
+ const result = await transport.simplePost(url, headers, body, 30);
48
+ return {
49
+ status: result.status,
50
+ body: result.body,
51
+ ok: result.status >= 200 && result.status < 300,
52
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  }
src/tls/libcurl-ffi-transport.ts ADDED
@@ -0,0 +1,505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * LibcurlFfiTransport — TLS transport using koffi FFI to libcurl-impersonate.
3
+ *
4
+ * Loads libcurl-impersonate shared library (DLL on Windows, .so/.dylib on others)
5
+ * and calls the C API directly. Uses curl_multi for non-blocking streaming.
6
+ *
7
+ * This provides Chrome TLS fingerprint on Windows where the curl-impersonate
8
+ * CLI binary is not available.
9
+ */
10
+
11
+ import { resolve } from "path";
12
+ import { existsSync } from "fs";
13
+ import type { IKoffiLib, IKoffiCType, IKoffiRegisteredCallback, KoffiFunction } from "koffi";
14
+ import type { TlsTransport, TlsTransportResponse } from "./transport.js";
15
+ import { getProxyUrl } from "./curl-binary.js";
16
+
17
+ // ── libcurl constants ──────────────────────────────────────────────
18
+
19
+ const CURLOPT_URL = 10002;
20
+ const CURLOPT_HTTPHEADER = 10023;
21
+ const CURLOPT_POSTFIELDS = 10015;
22
+ const CURLOPT_POSTFIELDSIZE = 60;
23
+ const CURLOPT_WRITEFUNCTION = 20011;
24
+ const CURLOPT_HEADERFUNCTION = 20079;
25
+ const CURLOPT_POST = 47;
26
+ const CURLOPT_NOSIGNAL = 99;
27
+ const CURLOPT_TIMEOUT = 13;
28
+ const CURLOPT_PROXY = 10004;
29
+ const CURLOPT_CAINFO = 10065;
30
+ const CURLOPT_ACCEPT_ENCODING = 10102;
31
+ const CURLOPT_HTTP_VERSION = 84;
32
+ const CURL_HTTP_VERSION_2_0 = 3;
33
+ const CURLINFO_RESPONSE_CODE = 0x200002;
34
+ const CURLM_OK = 0;
35
+ const HEADER_TIMEOUT_MS = 30_000;
36
+
37
+ // ── Branded opaque handle types ──────────────────────────────────
38
+
39
+ /** Opaque C pointer returned by curl_easy_init(). */
40
+ type CurlHandle = { readonly __brand: "CURL" };
41
+ /** Opaque C pointer returned by curl_multi_init(). */
42
+ type CurlMultiHandle = { readonly __brand: "CURLM" };
43
+ /** Opaque C pointer for curl_slist linked list. */
44
+ type SlistHandle = { readonly __brand: "curl_slist" } | null;
45
+
46
+ /** koffi module loaded via dynamic import (same shape as `typeof import("koffi")`). */
47
+ type KoffiModule = typeof import("koffi");
48
+
49
+ // ── CurlBindings: strongly typed FFI function signatures ─────────
50
+
51
+ interface CurlBindings {
52
+ koffi: KoffiModule;
53
+ lib: IKoffiLib;
54
+ writeCallbackType: IKoffiCType;
55
+ headerCallbackType: IKoffiCType;
56
+ caPath: string | null;
57
+
58
+ curl_easy_init: KoffiFunction;
59
+ curl_easy_cleanup: KoffiFunction;
60
+ curl_easy_setopt_long: KoffiFunction;
61
+ curl_easy_setopt_str: KoffiFunction;
62
+ curl_easy_setopt_ptr: KoffiFunction;
63
+ curl_easy_setopt_cb: KoffiFunction;
64
+ curl_easy_setopt_header_cb: KoffiFunction;
65
+ curl_easy_getinfo_long: KoffiFunction;
66
+ curl_easy_impersonate: KoffiFunction;
67
+ curl_easy_perform: KoffiFunction;
68
+ curl_slist_append: KoffiFunction;
69
+ curl_slist_free_all: KoffiFunction;
70
+ curl_multi_init: KoffiFunction;
71
+ curl_multi_add_handle: KoffiFunction;
72
+ curl_multi_remove_handle: KoffiFunction;
73
+ curl_multi_perform: KoffiFunction;
74
+ curl_multi_poll: KoffiFunction;
75
+ curl_multi_cleanup: KoffiFunction;
76
+ }
77
+
78
+ /** Promisify a koffi KoffiFunction.async() call (callback as last arg). */
79
+ function asyncCall(fn: KoffiFunction, ...args: unknown[]): Promise<number> {
80
+ return new Promise((resolve, reject) => {
81
+ fn.async(...args, (err: unknown, result: number) => {
82
+ if (err) reject(err); else resolve(result);
83
+ });
84
+ });
85
+ }
86
+
87
+ // ── FFI initialization ─────────────────────────────────────────────
88
+
89
+ function resolveLibPath(): string | null {
90
+ const binDir = resolve(process.cwd(), "bin");
91
+ const candidates: string[] = [];
92
+
93
+ if (process.platform === "win32") {
94
+ // lexiforest/curl-impersonate ships the Windows DLL as libcurl.dll
95
+ candidates.push(resolve(binDir, "libcurl.dll"));
96
+ } else if (process.platform === "darwin") {
97
+ candidates.push(resolve(binDir, "libcurl-impersonate.dylib"));
98
+ } else {
99
+ candidates.push(resolve(binDir, "libcurl-impersonate.so"));
100
+ }
101
+
102
+ for (const p of candidates) {
103
+ if (existsSync(p)) return p;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ function resolveCaPath(): string | null {
109
+ const candidate = resolve(process.cwd(), "bin", "cacert.pem");
110
+ return existsSync(candidate) ? candidate : null;
111
+ }
112
+
113
+ async function initBindings(): Promise<CurlBindings> {
114
+ let koffi: KoffiModule;
115
+ try {
116
+ const mod = await import("koffi");
117
+ koffi = mod.default ?? mod;
118
+ } catch {
119
+ throw new Error("koffi package not installed. Run: npm install koffi");
120
+ }
121
+
122
+ const dllPath = resolveLibPath();
123
+ if (!dllPath) {
124
+ throw new Error(
125
+ "libcurl-impersonate shared library not found. Run: npm run setup",
126
+ );
127
+ }
128
+
129
+ const lib: IKoffiLib = koffi.load(dllPath);
130
+
131
+ // Define opaque pointer types (referenced by string name in signatures)
132
+ koffi.pointer("CURL", koffi.opaque());
133
+ koffi.pointer("CURLM", koffi.opaque());
134
+ koffi.pointer("curl_slist", koffi.opaque());
135
+
136
+ // Callback prototypes
137
+ const writeCallbackType: IKoffiCType = koffi.proto("size_t write_cb(const uint8_t *ptr, size_t size, size_t nmemb, intptr_t userdata)");
138
+ const headerCallbackType: IKoffiCType = koffi.proto("size_t header_cb(const uint8_t *ptr, size_t size, size_t nmemb, intptr_t userdata)");
139
+
140
+ // Bind functions — use string names for pointer types (not template literals)
141
+ const curl_global_init = lib.func("int curl_global_init(int flags)");
142
+ const curl_easy_init = lib.func("CURL *curl_easy_init()");
143
+ const curl_easy_cleanup = lib.func("void curl_easy_cleanup(CURL *handle)");
144
+ const curl_easy_setopt_long = lib.func("int curl_easy_setopt(CURL *handle, int option, long value)");
145
+ const curl_easy_setopt_str = lib.func("int curl_easy_setopt(CURL *handle, int option, const char *value)");
146
+ const curl_easy_setopt_ptr = lib.func("int curl_easy_setopt(CURL *handle, int option, curl_slist *value)");
147
+ const curl_easy_setopt_cb = lib.func("int curl_easy_setopt(CURL *handle, int option, write_cb *value)");
148
+ const curl_easy_setopt_header_cb = lib.func("int curl_easy_setopt(CURL *handle, int option, header_cb *value)");
149
+ const curl_easy_getinfo_long = lib.func("int curl_easy_getinfo(CURL *handle, int info, _Out_ int *value)");
150
+ const curl_easy_impersonate = lib.func("int curl_easy_impersonate(CURL *handle, const char *target, int default_headers)");
151
+ const curl_easy_perform = lib.func("int curl_easy_perform(CURL *handle)");
152
+ const curl_slist_append = lib.func("curl_slist *curl_slist_append(curl_slist *list, const char *string)");
153
+ const curl_slist_free_all = lib.func("void curl_slist_free_all(curl_slist *list)");
154
+ const curl_multi_init = lib.func("CURLM *curl_multi_init()");
155
+ const curl_multi_add_handle = lib.func("int curl_multi_add_handle(CURLM *multi, CURL *easy)");
156
+ const curl_multi_remove_handle = lib.func("int curl_multi_remove_handle(CURLM *multi, CURL *easy)");
157
+ const curl_multi_perform = lib.func("int curl_multi_perform(CURLM *multi, _Out_ int *running_handles)");
158
+ const curl_multi_poll = lib.func("int curl_multi_poll(CURLM *multi, void *extra_fds, int extra_nfds, int timeout_ms, _Out_ int *numfds)");
159
+ const curl_multi_cleanup = lib.func("int curl_multi_cleanup(CURLM *multi)");
160
+
161
+ // Global init (CURL_GLOBAL_DEFAULT = 3)
162
+ curl_global_init(3);
163
+
164
+ const caPath = resolveCaPath();
165
+ if (caPath) {
166
+ console.log(`[TLS/FFI] Using CA bundle: ${caPath}`);
167
+ } else {
168
+ console.warn("[TLS/FFI] No CA bundle at bin/cacert.pem — HTTPS may fail");
169
+ }
170
+
171
+ return {
172
+ koffi,
173
+ lib,
174
+ writeCallbackType,
175
+ headerCallbackType,
176
+ caPath,
177
+ curl_easy_init,
178
+ curl_easy_cleanup,
179
+ curl_easy_setopt_long,
180
+ curl_easy_setopt_str,
181
+ curl_easy_setopt_ptr,
182
+ curl_easy_setopt_cb,
183
+ curl_easy_setopt_header_cb,
184
+ curl_easy_getinfo_long,
185
+ curl_easy_impersonate,
186
+ curl_easy_perform,
187
+ curl_slist_append,
188
+ curl_slist_free_all,
189
+ curl_multi_init,
190
+ curl_multi_add_handle,
191
+ curl_multi_remove_handle,
192
+ curl_multi_perform,
193
+ curl_multi_poll,
194
+ curl_multi_cleanup,
195
+ };
196
+ }
197
+
198
+ // ── Transport implementation ───────────────────────────────────────
199
+
200
+ export class LibcurlFfiTransport implements TlsTransport {
201
+ private b: CurlBindings;
202
+
203
+ constructor(bindings: CurlBindings) {
204
+ this.b = bindings;
205
+ }
206
+
207
+ /**
208
+ * Streaming POST using curl_multi event loop.
209
+ * Data arrives via WRITEFUNCTION callback → pushed into ReadableStream.
210
+ */
211
+ post(
212
+ url: string,
213
+ headers: Record<string, string>,
214
+ body: string,
215
+ signal?: AbortSignal,
216
+ timeoutSec?: number,
217
+ ): Promise<TlsTransportResponse> {
218
+ return new Promise((resolve, reject) => {
219
+ if (signal?.aborted) {
220
+ reject(new Error("Aborted"));
221
+ return;
222
+ }
223
+
224
+ const { easy, slist } = this.setupEasyHandle(url, headers, {
225
+ method: "POST",
226
+ body,
227
+ timeoutSec,
228
+ });
229
+
230
+ const b = this.b;
231
+ let bodyController: ReadableStreamDefaultController<Uint8Array> | null = null;
232
+ let headersParsed = false;
233
+ let statusCode = 0;
234
+ const responseHeaders = new Headers();
235
+ const setCookieHeaders: string[] = [];
236
+
237
+ // Register persistent WRITEFUNCTION callback
238
+ const writeCallback: IKoffiRegisteredCallback = b.koffi.register(
239
+ (ptr: unknown, size: number, nmemb: number, _userdata: unknown): number => {
240
+ const totalBytes = size * nmemb;
241
+ if (totalBytes === 0) return 0;
242
+ const arr = b.koffi.decode(ptr, "uint8_t", totalBytes) as number[];
243
+ const chunk = new Uint8Array(arr);
244
+ bodyController?.enqueue(chunk);
245
+ return totalBytes;
246
+ },
247
+ b.koffi.pointer(b.writeCallbackType),
248
+ );
249
+
250
+ // Register HEADERFUNCTION callback to capture response headers
251
+ const headerCallback: IKoffiRegisteredCallback = b.koffi.register(
252
+ (ptr: unknown, size: number, nmemb: number, _userdata: unknown): number => {
253
+ const totalBytes = size * nmemb;
254
+ if (totalBytes === 0) return 0;
255
+ const arr = b.koffi.decode(ptr, "uint8_t", totalBytes) as number[];
256
+ const line = Buffer.from(arr).toString("utf-8");
257
+
258
+ const statusMatch = line.match(/^HTTP\/[\d.]+ (\d+)/);
259
+ if (statusMatch) {
260
+ statusCode = parseInt(statusMatch[1], 10);
261
+ return totalBytes;
262
+ }
263
+
264
+ const colonIdx = line.indexOf(":");
265
+ if (colonIdx !== -1) {
266
+ const key = line.slice(0, colonIdx).trim();
267
+ const value = line.slice(colonIdx + 1).trim();
268
+ if (key.toLowerCase() === "set-cookie") {
269
+ setCookieHeaders.push(value);
270
+ }
271
+ responseHeaders.append(key, value);
272
+ }
273
+
274
+ return totalBytes;
275
+ },
276
+ b.koffi.pointer(b.headerCallbackType),
277
+ );
278
+
279
+ b.curl_easy_setopt_cb(easy, CURLOPT_WRITEFUNCTION, writeCallback);
280
+ b.curl_easy_setopt_header_cb(easy, CURLOPT_HEADERFUNCTION, headerCallback);
281
+
282
+ // Create ReadableStream for the body
283
+ let aborted = false;
284
+ const bodyStream = new ReadableStream<Uint8Array>({
285
+ start(c) {
286
+ bodyController = c;
287
+ },
288
+ cancel() {
289
+ aborted = true;
290
+ },
291
+ });
292
+
293
+ const onAbort = () => {
294
+ aborted = true;
295
+ };
296
+ if (signal) {
297
+ signal.addEventListener("abort", onAbort, { once: true });
298
+ }
299
+
300
+ // Use curl_multi for non-blocking operation
301
+ const multi = b.curl_multi_init() as CurlMultiHandle;
302
+ b.curl_multi_add_handle(multi, easy);
303
+
304
+ const runningHandles = new Int32Array(1);
305
+ const numfds = new Int32Array(1);
306
+ let resolved = false;
307
+
308
+ const cleanup = () => {
309
+ b.curl_multi_remove_handle(multi, easy);
310
+ b.curl_multi_cleanup(multi);
311
+ b.curl_easy_cleanup(easy);
312
+ if (slist) b.curl_slist_free_all(slist);
313
+ b.koffi.unregister(writeCallback);
314
+ b.koffi.unregister(headerCallback);
315
+ if (signal) signal.removeEventListener("abort", onAbort);
316
+ };
317
+
318
+ const pollLoop = async () => {
319
+ try {
320
+ while (!aborted) {
321
+ const pollResult = await asyncCall(b.curl_multi_poll, multi, null, 0, 100, numfds);
322
+ if (pollResult !== CURLM_OK) break;
323
+
324
+ const performResult = await asyncCall(b.curl_multi_perform, multi, runningHandles);
325
+ if (performResult !== CURLM_OK) break;
326
+
327
+ // After headers are received, resolve the promise
328
+ if (!resolved && statusCode > 0) {
329
+ resolved = true;
330
+ headersParsed = true;
331
+ resolve({
332
+ status: statusCode,
333
+ headers: responseHeaders,
334
+ body: bodyStream,
335
+ setCookieHeaders,
336
+ });
337
+ }
338
+
339
+ if (runningHandles[0] === 0) break;
340
+ }
341
+ } catch (err) {
342
+ if (!resolved) {
343
+ reject(err instanceof Error ? err : new Error(String(err)));
344
+ }
345
+ } finally {
346
+ cleanup();
347
+ bodyController?.close();
348
+
349
+ if (!resolved) {
350
+ reject(new Error("curl: transfer completed without receiving headers"));
351
+ }
352
+ }
353
+ };
354
+
355
+ // Header timeout
356
+ const headerTimer = setTimeout(() => {
357
+ if (!headersParsed) {
358
+ aborted = true;
359
+ if (!resolved) {
360
+ reject(new Error(`curl header parse timeout after ${HEADER_TIMEOUT_MS}ms`));
361
+ }
362
+ }
363
+ }, HEADER_TIMEOUT_MS);
364
+ if (headerTimer.unref) headerTimer.unref();
365
+
366
+ pollLoop().finally(() => clearTimeout(headerTimer));
367
+ });
368
+ }
369
+
370
+ async get(
371
+ url: string,
372
+ headers: Record<string, string>,
373
+ timeoutSec = 30,
374
+ ): Promise<{ status: number; body: string }> {
375
+ return this.simpleRequest(url, headers, undefined, timeoutSec);
376
+ }
377
+
378
+ async simplePost(
379
+ url: string,
380
+ headers: Record<string, string>,
381
+ body: string,
382
+ timeoutSec = 30,
383
+ ): Promise<{ status: number; body: string }> {
384
+ return this.simpleRequest(url, headers, body, timeoutSec);
385
+ }
386
+
387
+ isImpersonate(): boolean {
388
+ return true;
389
+ }
390
+
391
+ private async simpleRequest(
392
+ url: string,
393
+ headers: Record<string, string>,
394
+ body: string | undefined,
395
+ timeoutSec: number,
396
+ ): Promise<{ status: number; body: string }> {
397
+ const b = this.b;
398
+ const { easy, slist } = this.setupEasyHandle(url, headers, {
399
+ method: body !== undefined ? "POST" : "GET",
400
+ body,
401
+ timeoutSec,
402
+ });
403
+
404
+ const chunks: Buffer[] = [];
405
+
406
+ const writeCallback: IKoffiRegisteredCallback = b.koffi.register(
407
+ (ptr: unknown, size: number, nmemb: number, _userdata: unknown): number => {
408
+ const totalBytes = size * nmemb;
409
+ if (totalBytes === 0) return 0;
410
+ const arr = b.koffi.decode(ptr, "uint8_t", totalBytes) as number[];
411
+ chunks.push(Buffer.from(arr));
412
+ return totalBytes;
413
+ },
414
+ b.koffi.pointer(b.writeCallbackType),
415
+ );
416
+
417
+ b.curl_easy_setopt_cb(easy, CURLOPT_WRITEFUNCTION, writeCallback);
418
+
419
+ try {
420
+ const result = await asyncCall(b.curl_easy_perform, easy);
421
+ if (result !== 0) {
422
+ throw new Error(`curl_easy_perform failed with code ${result}`);
423
+ }
424
+
425
+ const statusBuf = new Int32Array(1);
426
+ b.curl_easy_getinfo_long(easy, CURLINFO_RESPONSE_CODE, statusBuf);
427
+ const status = statusBuf[0];
428
+
429
+ const responseBody = Buffer.concat(chunks).toString("utf-8");
430
+ return { status, body: responseBody };
431
+ } finally {
432
+ b.curl_easy_cleanup(easy);
433
+ if (slist) b.curl_slist_free_all(slist);
434
+ b.koffi.unregister(writeCallback);
435
+ }
436
+ }
437
+
438
+ /** Setup a curl easy handle with common options. */
439
+ private setupEasyHandle(
440
+ url: string,
441
+ headers: Record<string, string>,
442
+ opts: {
443
+ method?: "GET" | "POST";
444
+ body?: string;
445
+ timeoutSec?: number;
446
+ } = {},
447
+ ): { easy: CurlHandle; slist: SlistHandle } {
448
+ const b = this.b;
449
+ const easy = b.curl_easy_init() as CurlHandle;
450
+ if (!easy) throw new Error("curl_easy_init() returned null");
451
+
452
+ // Impersonate Chrome — 0 = don't inject default headers (we control them)
453
+ b.curl_easy_impersonate(easy, "chrome136", 0);
454
+
455
+ b.curl_easy_setopt_str(easy, CURLOPT_URL, url);
456
+ b.curl_easy_setopt_long(easy, CURLOPT_NOSIGNAL, 1);
457
+ b.curl_easy_setopt_long(easy, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2_0);
458
+
459
+ // Accept-Encoding — let libcurl handle decompression
460
+ b.curl_easy_setopt_str(easy, CURLOPT_ACCEPT_ENCODING, "");
461
+
462
+ // CA bundle for BoringSSL (not using system cert store)
463
+ if (b.caPath) {
464
+ b.curl_easy_setopt_str(easy, CURLOPT_CAINFO, b.caPath);
465
+ }
466
+
467
+ if (opts.timeoutSec) {
468
+ b.curl_easy_setopt_long(easy, CURLOPT_TIMEOUT, opts.timeoutSec);
469
+ }
470
+
471
+ // Proxy
472
+ const proxyUrl = getProxyUrl();
473
+ if (proxyUrl) {
474
+ b.curl_easy_setopt_str(easy, CURLOPT_PROXY, proxyUrl);
475
+ }
476
+
477
+ // Headers — build slist
478
+ let slist: SlistHandle = null;
479
+ for (const [key, value] of Object.entries(headers)) {
480
+ slist = b.curl_slist_append(slist, `${key}: ${value}`) as SlistHandle;
481
+ }
482
+ slist = b.curl_slist_append(slist, "Expect:") as SlistHandle;
483
+ if (slist) {
484
+ b.curl_easy_setopt_ptr(easy, CURLOPT_HTTPHEADER, slist);
485
+ }
486
+
487
+ // POST body
488
+ if (opts.method === "POST" || opts.body !== undefined) {
489
+ const postBody = opts.body ?? "";
490
+ b.curl_easy_setopt_long(easy, CURLOPT_POST, 1);
491
+ b.curl_easy_setopt_str(easy, CURLOPT_POSTFIELDS, postBody);
492
+ b.curl_easy_setopt_long(easy, CURLOPT_POSTFIELDSIZE, Buffer.byteLength(postBody, "utf-8"));
493
+ }
494
+
495
+ return { easy, slist };
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Async factory — loads koffi + libcurl-impersonate and returns a transport instance.
501
+ */
502
+ export async function createLibcurlFfiTransport(): Promise<LibcurlFfiTransport> {
503
+ const bindings = await initBindings();
504
+ return new LibcurlFfiTransport(bindings);
505
+ }
src/tls/transport.ts ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * TLS Transport abstraction — decouples upstream request logic from
3
+ * the concrete transport (curl CLI subprocess vs libcurl FFI).
4
+ *
5
+ * Singleton: call initTransport() once at startup, then getTransport() anywhere.
6
+ */
7
+
8
+ import { existsSync } from "fs";
9
+ import { resolve } from "path";
10
+
11
+ export interface TlsTransportResponse {
12
+ status: number;
13
+ headers: Headers;
14
+ body: ReadableStream<Uint8Array>;
15
+ setCookieHeaders: string[];
16
+ }
17
+
18
+ export interface TlsTransport {
19
+ /** Streaming POST (for SSE). Returns headers + streaming body. */
20
+ post(
21
+ url: string,
22
+ headers: Record<string, string>,
23
+ body: string,
24
+ signal?: AbortSignal,
25
+ timeoutSec?: number,
26
+ ): Promise<TlsTransportResponse>;
27
+
28
+ /** Simple GET — returns full body as string. */
29
+ get(
30
+ url: string,
31
+ headers: Record<string, string>,
32
+ timeoutSec?: number,
33
+ ): Promise<{ status: number; body: string }>;
34
+
35
+ /** Simple (non-streaming) POST — returns full body as string. */
36
+ simplePost(
37
+ url: string,
38
+ headers: Record<string, string>,
39
+ body: string,
40
+ timeoutSec?: number,
41
+ ): Promise<{ status: number; body: string }>;
42
+
43
+ /** Whether this transport provides a Chrome TLS fingerprint. */
44
+ isImpersonate(): boolean;
45
+ }
46
+
47
+ let _transport: TlsTransport | null = null;
48
+
49
+ /**
50
+ * Initialize the transport singleton. Must be called once at startup
51
+ * after config and proxy detection are ready.
52
+ */
53
+ export async function initTransport(): Promise<TlsTransport> {
54
+ if (_transport) return _transport;
55
+
56
+ const { getConfig } = await import("../config.js");
57
+ const config = getConfig();
58
+ const setting = config.tls.transport ?? "auto";
59
+
60
+ if (setting === "libcurl-ffi" || (setting === "auto" && shouldUseFfi())) {
61
+ try {
62
+ const { createLibcurlFfiTransport } = await import("./libcurl-ffi-transport.js");
63
+ _transport = await createLibcurlFfiTransport();
64
+ console.log("[TLS] Using libcurl-impersonate FFI transport");
65
+ return _transport;
66
+ } catch (err) {
67
+ const msg = err instanceof Error ? err.message : String(err);
68
+ if (setting === "libcurl-ffi") {
69
+ throw new Error(`Failed to initialize libcurl FFI transport: ${msg}`);
70
+ }
71
+ console.warn(`[TLS] FFI transport unavailable (${msg}), falling back to curl CLI`);
72
+ }
73
+ }
74
+
75
+ const { CurlCliTransport } = await import("./curl-cli-transport.js");
76
+ _transport = new CurlCliTransport();
77
+ console.log("[TLS] Using curl CLI transport");
78
+ return _transport;
79
+ }
80
+
81
+ /**
82
+ * Get the initialized transport. Throws if initTransport() hasn't been called.
83
+ */
84
+ export function getTransport(): TlsTransport {
85
+ if (!_transport) throw new Error("Transport not initialized. Call initTransport() first.");
86
+ return _transport;
87
+ }
88
+
89
+ /**
90
+ * Determine if FFI transport should be used in "auto" mode.
91
+ * FFI is preferred on Windows where curl-impersonate CLI is unavailable.
92
+ */
93
+ function shouldUseFfi(): boolean {
94
+ if (process.platform !== "win32") return false;
95
+
96
+ // Check if libcurl-impersonate DLL exists (shipped as libcurl.dll)
97
+ const dllPath = resolve(process.cwd(), "bin", "libcurl.dll");
98
+ return existsSync(dllPath);
99
+ }
100
+
101
+ /** Reset transport singleton (for testing). */
102
+ export function resetTransport(): void {
103
+ _transport = null;
104
+ }