refactor(nodejs-sdk): replace axios with fetch transport (#34325)

This commit is contained in:
yyh
2026-03-31 16:41:30 +08:00
committed by GitHub
parent b54a0dc1e4
commit fbd2d31624
41 changed files with 1673 additions and 1118 deletions

215
pnpm-lock.yaml generated
View File

@@ -235,8 +235,8 @@ catalogs:
specifier: 0.5.21
version: 0.5.21
'@vitest/coverage-v8':
specifier: 4.1.2
version: 4.1.2
specifier: 4.1.1
version: 4.1.1
abcjs:
specifier: 6.6.2
version: 6.6.2
@@ -570,7 +570,6 @@ overrides:
array.prototype.flatmap: npm:@nolyfill/array.prototype.flatmap@^1.0.44
array.prototype.tosorted: npm:@nolyfill/array.prototype.tosorted@^1.0.44
assert: npm:@nolyfill/assert@^1.0.26
axios: 1.14.0
brace-expansion@<2.0.2: 2.0.2
canvas: ^3.2.2
devalue@<5.3.2: 5.3.2
@@ -648,10 +647,6 @@ importers:
version: 0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)
sdks/nodejs-client:
dependencies:
axios:
specifier: 1.14.0
version: 1.14.0
devDependencies:
'@eslint/js':
specifier: 'catalog:'
@@ -667,7 +662,7 @@ importers:
version: 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.1.2(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))
version: 4.1.1(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))
eslint:
specifier: 'catalog:'
version: 10.1.0(jiti@2.6.1)
@@ -1124,7 +1119,7 @@ importers:
version: 0.5.21(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.4)
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.1.2(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))
version: 4.1.1(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))
agentation:
specifier: 'catalog:'
version: 3.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -2405,10 +2400,6 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@nolyfill/hasown@1.0.44':
resolution: {integrity: sha512-GA/21lkTr2PAQuT6jGnhLuBD5IFd/AEhBXJ/tf33+/bVxPxg+5ejKx9jGQGnyV/P0eSmdup5E+s8b2HL6lOrwQ==}
engines: {node: '>=12.4.0'}
'@nolyfill/is-core-module@1.0.39':
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
@@ -4440,11 +4431,11 @@ packages:
react-server-dom-webpack:
optional: true
'@vitest/coverage-v8@4.1.2':
resolution: {integrity: sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==}
'@vitest/coverage-v8@4.1.1':
resolution: {integrity: sha512-nZ4RWwGCoGOQRMmU/Q9wlUY540RVRxJZ9lxFsFfy0QV7Zmo5VVBhB6Sl9Xa0KIp2iIs3zWfPlo9LcY1iqbpzCw==}
peerDependencies:
'@vitest/browser': 4.1.2
vitest: 4.1.2
'@vitest/browser': 4.1.1
vitest: 4.1.1
peerDependenciesMeta:
'@vitest/browser':
optional: true
@@ -4471,8 +4462,8 @@ packages:
'@vitest/pretty-format@3.2.4':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/pretty-format@4.1.2':
resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==}
'@vitest/pretty-format@4.1.1':
resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==}
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
@@ -4480,8 +4471,8 @@ packages:
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
'@vitest/utils@4.1.2':
resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==}
'@vitest/utils@4.1.1':
resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==}
'@voidzero-dev/vite-plus-core@0.1.14':
resolution: {integrity: sha512-CCWzdkfW0fo0cQNlIsYp5fOuH2IwKuPZEb2UY2Z8gXcp5pG74A82H2Pthj0heAuvYTAnfT7kEC6zM+RbiBgQbg==}
@@ -4841,9 +4832,6 @@ packages:
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
autoprefixer@10.4.27:
resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==}
engines: {node: ^10 || ^12 || >=14}
@@ -4851,9 +4839,6 @@ packages:
peerDependencies:
postcss: ^8.1.0
axios@1.14.0:
resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==}
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
@@ -4951,10 +4936,6 @@ packages:
resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==}
engines: {node: '>=20.19.0'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
@@ -5126,10 +5107,6 @@ packages:
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
comma-separated-tokens@1.0.8:
resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==}
@@ -5464,10 +5441,6 @@ packages:
delaunator@5.1.0:
resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -5533,10 +5506,6 @@ packages:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
echarts-for-react@3.0.6:
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
peerDependencies:
@@ -5613,28 +5582,12 @@ packages:
error-stack-parser@2.1.4:
resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-module-lexer@2.0.0:
resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
es-toolkit@1.45.1:
resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==}
@@ -6115,19 +6068,6 @@ packages:
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
format@0.2.2:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
@@ -6164,9 +6104,6 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
functional-red-black-tree@1.0.1:
resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==}
@@ -6181,18 +6118,10 @@ packages:
resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==}
engines: {node: '>=18'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
get-stream@5.2.0:
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
engines: {node: '>=8'}
@@ -6249,10 +6178,6 @@ packages:
peerDependencies:
csstype: ^3.0.10
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -6271,14 +6196,6 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hast-util-from-dom@5.0.1:
resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
@@ -6920,10 +6837,6 @@ packages:
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mdast-util-directive@3.1.0:
resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==}
@@ -7651,10 +7564,6 @@ packages:
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
@@ -10497,8 +10406,6 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@nolyfill/hasown@1.0.44': {}
'@nolyfill/is-core-module@1.0.39': {}
'@nolyfill/safer-buffer@1.0.44': {}
@@ -12354,10 +12261,10 @@ snapshots:
optionalDependencies:
react-server-dom-webpack: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))
'@vitest/coverage-v8@4.1.2(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))':
'@vitest/coverage-v8@4.1.1(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.2
'@vitest/utils': 4.1.1
ast-v8-to-istanbul: 1.0.0
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
@@ -12368,10 +12275,10 @@ snapshots:
tinyrainbow: 3.1.0
vitest: '@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)'
'@vitest/coverage-v8@4.1.2(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))':
'@vitest/coverage-v8@4.1.1(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.2
'@vitest/utils': 4.1.1
ast-v8-to-istanbul: 1.0.0
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
@@ -12406,7 +12313,7 @@ snapshots:
dependencies:
tinyrainbow: 2.0.0
'@vitest/pretty-format@4.1.2':
'@vitest/pretty-format@4.1.1':
dependencies:
tinyrainbow: 3.1.0
@@ -12420,9 +12327,9 @@ snapshots:
loupe: 3.2.1
tinyrainbow: 2.0.0
'@vitest/utils@4.1.2':
'@vitest/utils@4.1.1':
dependencies:
'@vitest/pretty-format': 4.1.2
'@vitest/pretty-format': 4.1.1
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
@@ -12816,8 +12723,6 @@ snapshots:
async@3.2.6: {}
asynckit@0.4.0: {}
autoprefixer@10.4.27(postcss@8.5.8):
dependencies:
browserslist: 4.28.1
@@ -12827,14 +12732,6 @@ snapshots:
postcss: 8.5.8
postcss-value-parser: 4.2.0
axios@1.14.0:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
bail@2.0.2: {}
balanced-match@1.0.2: {}
@@ -12914,11 +12811,6 @@ snapshots:
cac@7.0.0: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
callsites@3.1.0: {}
camelcase-css@2.0.1: {}
@@ -13108,10 +13000,6 @@ snapshots:
colorette@2.0.20: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
comma-separated-tokens@1.0.8: {}
comma-separated-tokens@2.0.3: {}
@@ -13441,8 +13329,6 @@ snapshots:
dependencies:
robust-predicates: 3.0.3
delayed-stream@1.0.0: {}
dequal@2.0.3: {}
destr@2.0.5: {}
@@ -13499,12 +13385,6 @@ snapshots:
dotenv@16.6.1: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.4):
dependencies:
echarts: 6.0.0
@@ -13571,25 +13451,10 @@ snapshots:
dependencies:
stackframe: 1.3.4
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-module-lexer@1.7.0: {}
es-module-lexer@2.0.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: '@nolyfill/hasown@1.0.44'
es-toolkit@1.45.1: {}
esast-util-from-estree@2.0.0:
@@ -14344,16 +14209,6 @@ snapshots:
flatted@3.4.2: {}
follow-redirects@1.15.11: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: '@nolyfill/hasown@1.0.44'
mime-types: 2.1.35
format@0.2.2: {}
formatly@0.3.0:
@@ -14380,8 +14235,6 @@ snapshots:
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
functional-red-black-tree@1.0.1: {}
fzf@0.5.2: {}
@@ -14390,26 +14243,8 @@ snapshots:
get-east-asian-width@1.5.0: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: '@nolyfill/hasown@1.0.44'
math-intrinsics: 1.1.0
get-nonce@1.0.1: {}
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
get-stream@5.2.0:
dependencies:
pump: 3.0.4
@@ -14457,8 +14292,6 @@ snapshots:
dependencies:
csstype: 3.2.3
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
hachure-fill@0.5.2: {}
@@ -14481,12 +14314,6 @@ snapshots:
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hast-util-from-dom@5.0.1:
dependencies:
'@types/hast': 3.0.4
@@ -15127,8 +14954,6 @@ snapshots:
marked@17.0.5: {}
math-intrinsics@1.1.0: {}
mdast-util-directive@3.1.0:
dependencies:
'@types/mdast': 4.0.4
@@ -16267,8 +16092,6 @@ snapshots:
property-information@7.1.0: {}
proxy-from-env@2.1.0: {}
pump@3.0.4:
dependencies:
end-of-stream: 1.4.5

View File

@@ -22,7 +22,6 @@ overrides:
array.prototype.flatmap: npm:@nolyfill/array.prototype.flatmap@^1.0.44
array.prototype.tosorted: npm:@nolyfill/array.prototype.tosorted@^1.0.44
assert: npm:@nolyfill/assert@^1.0.26
axios: 1.14.0
brace-expansion@<2.0.2: 2.0.2
canvas: ^3.2.2
devalue@<5.3.2: 5.3.2
@@ -147,12 +146,11 @@ catalog:
"@typescript/native-preview": 7.0.0-dev.20260329.1
"@vitejs/plugin-react": 6.0.1
"@vitejs/plugin-rsc": 0.5.21
"@vitest/coverage-v8": 4.1.2
"@vitest/coverage-v8": 4.1.1
abcjs: 6.6.2
agentation: 3.0.2
ahooks: 3.9.7
autoprefixer: 10.4.27
axios: 1.14.0
class-variance-authority: 0.7.1
clsx: 2.1.1
cmdk: 1.1.1

View File

@@ -12,11 +12,11 @@ const typeCheckedRules =
export default [
{
ignores: ["dist", "node_modules", "scripts", "tests", "**/*.test.*", "**/*.spec.*"],
ignores: ["dist", "node_modules", "scripts"],
},
js.configs.recommended,
{
files: ["src/**/*.ts"],
files: ["src/**/*.ts", "tests/**/*.ts"],
languageOptions: {
parser: tsParser,
ecmaVersion: "latest",

View File

@@ -1,6 +1,6 @@
{
"name": "dify-client",
"version": "3.0.0",
"version": "3.1.0",
"description": "This is the Node.js SDK for the Dify.AI API, which allows you to easily integrate Dify.AI into your Node.js applications.",
"type": "module",
"main": "./dist/index.js",
@@ -15,7 +15,8 @@
"node": ">=18.0.0"
},
"files": [
"dist",
"dist/index.js",
"dist/index.d.ts",
"README.md",
"LICENSE"
],
@@ -53,9 +54,6 @@
"publish:check": "./scripts/publish.sh --dry-run",
"publish:npm": "./scripts/publish.sh"
},
"dependencies": {
"axios": "catalog:"
},
"devDependencies": {
"@eslint/js": "catalog:",
"@types/node": "catalog:",

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DifyClient } from "./base";
import { ValidationError } from "../errors/dify-error";
import { DifyClient } from "./base";
import { createHttpClientWithSpies } from "../../tests/test-utils";
describe("DifyClient base", () => {
@@ -103,7 +103,7 @@ describe("DifyClient base", () => {
});
});
it("filePreview uses arraybuffer response", async () => {
it("filePreview uses bytes response", async () => {
const { client, request } = createHttpClientWithSpies();
const dify = new DifyClient(client);
@@ -113,7 +113,7 @@ describe("DifyClient base", () => {
method: "GET",
path: "/files/file/preview",
query: { user: "user", as_attachment: "true" },
responseType: "arraybuffer",
responseType: "bytes",
});
});
@@ -162,11 +162,11 @@ describe("DifyClient base", () => {
streaming: false,
voice: "voice",
},
responseType: "arraybuffer",
responseType: "bytes",
});
});
it("textToAudio requires text or message id", async () => {
it("textToAudio requires text or message id", () => {
const { client } = createHttpClientWithSpies();
const dify = new DifyClient(client);

View File

@@ -2,14 +2,18 @@ import type {
BinaryStream,
DifyClientConfig,
DifyResponse,
JsonObject,
MessageFeedbackRequest,
QueryParams,
RequestMethod,
SuccessResponse,
TextToAudioRequest,
} from "../types/common";
import type { HttpRequestBody } from "../http/client";
import { HttpClient } from "../http/client";
import { ensureNonEmptyString, ensureRating } from "./validation";
import { FileUploadError, ValidationError } from "../errors/dify-error";
import type { SdkFormData } from "../http/form-data";
import { isFormData } from "../http/form-data";
const toConfig = (
@@ -25,13 +29,8 @@ const toConfig = (
return init;
};
const appendUserToFormData = (form: unknown, user: string): void => {
if (!isFormData(form)) {
throw new FileUploadError("FormData is required for file uploads");
}
if (typeof form.append === "function") {
form.append("user", user);
}
const appendUserToFormData = (form: SdkFormData, user: string): void => {
form.append("user", user);
};
export class DifyClient {
@@ -57,7 +56,7 @@ export class DifyClient {
sendRequest(
method: RequestMethod,
endpoint: string,
data: unknown = null,
data: HttpRequestBody = null,
params: QueryParams | null = null,
stream = false,
headerParams: Record<string, string> = {}
@@ -72,14 +71,14 @@ export class DifyClient {
});
}
getRoot(): Promise<DifyResponse<unknown>> {
getRoot(): Promise<DifyResponse<JsonObject>> {
return this.http.request({
method: "GET",
path: "/",
});
}
getApplicationParameters(user?: string): Promise<DifyResponse<unknown>> {
getApplicationParameters(user?: string): Promise<DifyResponse<JsonObject>> {
if (user) {
ensureNonEmptyString(user, "user");
}
@@ -90,11 +89,11 @@ export class DifyClient {
});
}
async getParameters(user?: string): Promise<DifyResponse<unknown>> {
async getParameters(user?: string): Promise<DifyResponse<JsonObject>> {
return this.getApplicationParameters(user);
}
getMeta(user?: string): Promise<DifyResponse<unknown>> {
getMeta(user?: string): Promise<DifyResponse<JsonObject>> {
if (user) {
ensureNonEmptyString(user, "user");
}
@@ -107,21 +106,21 @@ export class DifyClient {
messageFeedback(
request: MessageFeedbackRequest
): Promise<DifyResponse<Record<string, unknown>>>;
): Promise<DifyResponse<SuccessResponse>>;
messageFeedback(
messageId: string,
rating: "like" | "dislike" | null,
user: string,
content?: string
): Promise<DifyResponse<Record<string, unknown>>>;
): Promise<DifyResponse<SuccessResponse>>;
messageFeedback(
messageIdOrRequest: string | MessageFeedbackRequest,
rating?: "like" | "dislike" | null,
user?: string,
content?: string
): Promise<DifyResponse<Record<string, unknown>>> {
): Promise<DifyResponse<SuccessResponse>> {
let messageId: string;
const payload: Record<string, unknown> = {};
const payload: JsonObject = {};
if (typeof messageIdOrRequest === "string") {
messageId = messageIdOrRequest;
@@ -157,7 +156,7 @@ export class DifyClient {
});
}
getInfo(user?: string): Promise<DifyResponse<unknown>> {
getInfo(user?: string): Promise<DifyResponse<JsonObject>> {
if (user) {
ensureNonEmptyString(user, "user");
}
@@ -168,7 +167,7 @@ export class DifyClient {
});
}
getSite(user?: string): Promise<DifyResponse<unknown>> {
getSite(user?: string): Promise<DifyResponse<JsonObject>> {
if (user) {
ensureNonEmptyString(user, "user");
}
@@ -179,7 +178,7 @@ export class DifyClient {
});
}
fileUpload(form: unknown, user: string): Promise<DifyResponse<unknown>> {
fileUpload(form: unknown, user: string): Promise<DifyResponse<JsonObject>> {
if (!isFormData(form)) {
throw new FileUploadError("FormData is required for file uploads");
}
@@ -199,18 +198,18 @@ export class DifyClient {
): Promise<DifyResponse<Buffer>> {
ensureNonEmptyString(fileId, "fileId");
ensureNonEmptyString(user, "user");
return this.http.request<Buffer>({
return this.http.request<Buffer, "bytes">({
method: "GET",
path: `/files/${fileId}/preview`,
query: {
user,
as_attachment: asAttachment ? "true" : undefined,
},
responseType: "arraybuffer",
responseType: "bytes",
});
}
audioToText(form: unknown, user: string): Promise<DifyResponse<unknown>> {
audioToText(form: unknown, user: string): Promise<DifyResponse<JsonObject>> {
if (!isFormData(form)) {
throw new FileUploadError("FormData is required for audio uploads");
}
@@ -274,11 +273,11 @@ export class DifyClient {
});
}
return this.http.request<Buffer>({
return this.http.request<Buffer, "bytes">({
method: "POST",
path: "/text-to-audio",
data: payload,
responseType: "arraybuffer",
responseType: "bytes",
});
}
}

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ChatClient } from "./chat";
import { ValidationError } from "../errors/dify-error";
import { ChatClient } from "./chat";
import { createHttpClientWithSpies } from "../../tests/test-utils";
describe("ChatClient", () => {
@@ -156,13 +156,13 @@ describe("ChatClient", () => {
});
});
it("requires name when autoGenerate is false", async () => {
it("requires name when autoGenerate is false", () => {
const { client } = createHttpClientWithSpies();
const chat = new ChatClient(client);
expect(() =>
chat.renameConversation("conv", "", "user", false)
).toThrow(ValidationError);
expect(() => chat.renameConversation("conv", "", "user", false)).toThrow(
ValidationError
);
});
it("deletes conversations", async () => {

View File

@@ -1,5 +1,9 @@
import { DifyClient } from "./base";
import type { ChatMessageRequest, ChatMessageResponse } from "../types/chat";
import type {
ChatMessageRequest,
ChatMessageResponse,
ConversationSortBy,
} from "../types/chat";
import type {
AnnotationCreateRequest,
AnnotationListOptions,
@@ -9,7 +13,11 @@ import type {
import type {
DifyResponse,
DifyStream,
JsonObject,
JsonValue,
QueryParams,
SuccessResponse,
SuggestedQuestionsResponse,
} from "../types/common";
import {
ensureNonEmptyString,
@@ -22,20 +30,20 @@ export class ChatClient extends DifyClient {
request: ChatMessageRequest
): Promise<DifyResponse<ChatMessageResponse> | DifyStream<ChatMessageResponse>>;
createChatMessage(
inputs: Record<string, unknown>,
inputs: JsonObject,
query: string,
user: string,
stream?: boolean,
conversationId?: string | null,
files?: Array<Record<string, unknown>> | null
files?: ChatMessageRequest["files"]
): Promise<DifyResponse<ChatMessageResponse> | DifyStream<ChatMessageResponse>>;
createChatMessage(
inputOrRequest: ChatMessageRequest | Record<string, unknown>,
inputOrRequest: ChatMessageRequest | JsonObject,
query?: string,
user?: string,
stream = false,
conversationId?: string | null,
files?: Array<Record<string, unknown>> | null
files?: ChatMessageRequest["files"]
): Promise<DifyResponse<ChatMessageResponse> | DifyStream<ChatMessageResponse>> {
let payload: ChatMessageRequest;
let shouldStream = stream;
@@ -46,8 +54,8 @@ export class ChatClient extends DifyClient {
} else {
ensureNonEmptyString(query, "query");
ensureNonEmptyString(user, "user");
payload = {
inputs: inputOrRequest as Record<string, unknown>,
payload = {
inputs: inputOrRequest,
query,
user,
response_mode: stream ? "streaming" : "blocking",
@@ -79,10 +87,10 @@ export class ChatClient extends DifyClient {
stopChatMessage(
taskId: string,
user: string
): Promise<DifyResponse<ChatMessageResponse>> {
): Promise<DifyResponse<SuccessResponse>> {
ensureNonEmptyString(taskId, "taskId");
ensureNonEmptyString(user, "user");
return this.http.request<ChatMessageResponse>({
return this.http.request<SuccessResponse>({
method: "POST",
path: `/chat-messages/${taskId}/stop`,
data: { user },
@@ -92,17 +100,17 @@ export class ChatClient extends DifyClient {
stopMessage(
taskId: string,
user: string
): Promise<DifyResponse<ChatMessageResponse>> {
): Promise<DifyResponse<SuccessResponse>> {
return this.stopChatMessage(taskId, user);
}
getSuggested(
messageId: string,
user: string
): Promise<DifyResponse<ChatMessageResponse>> {
): Promise<DifyResponse<SuggestedQuestionsResponse>> {
ensureNonEmptyString(messageId, "messageId");
ensureNonEmptyString(user, "user");
return this.http.request<ChatMessageResponse>({
return this.http.request<SuggestedQuestionsResponse>({
method: "GET",
path: `/messages/${messageId}/suggested`,
query: { user },
@@ -114,7 +122,7 @@ export class ChatClient extends DifyClient {
getAppFeedbacks(
page?: number,
limit?: number
): Promise<DifyResponse<Record<string, unknown>>> {
): Promise<DifyResponse<JsonObject>> {
ensureOptionalInt(page, "page");
ensureOptionalInt(limit, "limit");
return this.http.request({
@@ -131,8 +139,8 @@ export class ChatClient extends DifyClient {
user: string,
lastId?: string | null,
limit?: number | null,
sortByOrPinned?: string | boolean | null
): Promise<DifyResponse<Record<string, unknown>>> {
sortBy?: ConversationSortBy | null
): Promise<DifyResponse<JsonObject>> {
ensureNonEmptyString(user, "user");
ensureOptionalString(lastId, "lastId");
ensureOptionalInt(limit, "limit");
@@ -144,10 +152,8 @@ export class ChatClient extends DifyClient {
if (limit) {
params.limit = limit;
}
if (typeof sortByOrPinned === "string") {
params.sort_by = sortByOrPinned;
} else if (typeof sortByOrPinned === "boolean") {
params.pinned = sortByOrPinned;
if (sortBy) {
params.sort_by = sortBy;
}
return this.http.request({
@@ -162,7 +168,7 @@ export class ChatClient extends DifyClient {
conversationId: string,
firstId?: string | null,
limit?: number | null
): Promise<DifyResponse<Record<string, unknown>>> {
): Promise<DifyResponse<JsonObject>> {
ensureNonEmptyString(user, "user");
ensureNonEmptyString(conversationId, "conversationId");
ensureOptionalString(firstId, "firstId");
@@ -189,18 +195,18 @@ export class ChatClient extends DifyClient {
name: string,
user: string,
autoGenerate?: boolean
): Promise<DifyResponse<Record<string, unknown>>>;
): Promise<DifyResponse<JsonObject>>;
renameConversation(
conversationId: string,
user: string,
options?: { name?: string | null; autoGenerate?: boolean }
): Promise<DifyResponse<Record<string, unknown>>>;
): Promise<DifyResponse<JsonObject>>;
renameConversation(
conversationId: string,
nameOrUser: string,
userOrOptions?: string | { name?: string | null; autoGenerate?: boolean },
autoGenerate?: boolean
): Promise<DifyResponse<Record<string, unknown>>> {
): Promise<DifyResponse<JsonObject>> {
ensureNonEmptyString(conversationId, "conversationId");
let name: string | null | undefined;
@@ -222,7 +228,7 @@ export class ChatClient extends DifyClient {
ensureNonEmptyString(name, "name");
}
const payload: Record<string, unknown> = {
const payload: JsonObject = {
user,
auto_generate: resolvedAutoGenerate,
};
@@ -240,7 +246,7 @@ export class ChatClient extends DifyClient {
deleteConversation(
conversationId: string,
user: string
): Promise<DifyResponse<Record<string, unknown>>> {
): Promise<DifyResponse<SuccessResponse>> {
ensureNonEmptyString(conversationId, "conversationId");
ensureNonEmptyString(user, "user");
return this.http.request({
@@ -256,7 +262,7 @@ export class ChatClient extends DifyClient {
lastId?: string | null,
limit?: number | null,
variableName?: string | null
): Promise<DifyResponse<Record<string, unknown>>> {
): Promise<DifyResponse<JsonObject>> {
ensureNonEmptyString(conversationId, "conversationId");
ensureNonEmptyString(user, "user");
ensureOptionalString(lastId, "lastId");
@@ -279,8 +285,8 @@ export class ChatClient extends DifyClient {
conversationId: string,
variableId: string,
user: string,
value: unknown
): Promise<DifyResponse<Record<string, unknown>>> {
value: JsonValue
): Promise<DifyResponse<JsonObject>> {
ensureNonEmptyString(conversationId, "conversationId");
ensureNonEmptyString(variableId, "variableId");
ensureNonEmptyString(user, "user");

View File

@@ -1,6 +1,11 @@
import { DifyClient } from "./base";
import type { CompletionRequest, CompletionResponse } from "../types/completion";
import type { DifyResponse, DifyStream } from "../types/common";
import type {
DifyResponse,
DifyStream,
JsonObject,
SuccessResponse,
} from "../types/common";
import { ensureNonEmptyString } from "./validation";
const warned = new Set<string>();
@@ -17,16 +22,16 @@ export class CompletionClient extends DifyClient {
request: CompletionRequest
): Promise<DifyResponse<CompletionResponse> | DifyStream<CompletionResponse>>;
createCompletionMessage(
inputs: Record<string, unknown>,
inputs: JsonObject,
user: string,
stream?: boolean,
files?: Array<Record<string, unknown>> | null
files?: CompletionRequest["files"]
): Promise<DifyResponse<CompletionResponse> | DifyStream<CompletionResponse>>;
createCompletionMessage(
inputOrRequest: CompletionRequest | Record<string, unknown>,
inputOrRequest: CompletionRequest | JsonObject,
user?: string,
stream = false,
files?: Array<Record<string, unknown>> | null
files?: CompletionRequest["files"]
): Promise<DifyResponse<CompletionResponse> | DifyStream<CompletionResponse>> {
let payload: CompletionRequest;
let shouldStream = stream;
@@ -37,7 +42,7 @@ export class CompletionClient extends DifyClient {
} else {
ensureNonEmptyString(user, "user");
payload = {
inputs: inputOrRequest as Record<string, unknown>,
inputs: inputOrRequest,
user,
files,
response_mode: stream ? "streaming" : "blocking",
@@ -64,10 +69,10 @@ export class CompletionClient extends DifyClient {
stopCompletionMessage(
taskId: string,
user: string
): Promise<DifyResponse<CompletionResponse>> {
): Promise<DifyResponse<SuccessResponse>> {
ensureNonEmptyString(taskId, "taskId");
ensureNonEmptyString(user, "user");
return this.http.request<CompletionResponse>({
return this.http.request<SuccessResponse>({
method: "POST",
path: `/completion-messages/${taskId}/stop`,
data: { user },
@@ -77,15 +82,15 @@ export class CompletionClient extends DifyClient {
stop(
taskId: string,
user: string
): Promise<DifyResponse<CompletionResponse>> {
): Promise<DifyResponse<SuccessResponse>> {
return this.stopCompletionMessage(taskId, user);
}
runWorkflow(
inputs: Record<string, unknown>,
inputs: JsonObject,
user: string,
stream = false
): Promise<DifyResponse<Record<string, unknown>> | DifyStream<Record<string, unknown>>> {
): Promise<DifyResponse<JsonObject> | DifyStream<JsonObject>> {
warnOnce(
"CompletionClient.runWorkflow is deprecated. Use WorkflowClient.run instead."
);
@@ -96,13 +101,13 @@ export class CompletionClient extends DifyClient {
response_mode: stream ? "streaming" : "blocking",
};
if (stream) {
return this.http.requestStream<Record<string, unknown>>({
return this.http.requestStream<JsonObject>({
method: "POST",
path: "/workflows/run",
data: payload,
});
}
return this.http.request<Record<string, unknown>>({
return this.http.request<JsonObject>({
method: "POST",
path: "/workflows/run",
data: payload,

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FileUploadError, ValidationError } from "../errors/dify-error";
import { KnowledgeBaseClient } from "./knowledge-base";
import { createHttpClientWithSpies } from "../../tests/test-utils";
@@ -174,7 +175,6 @@ describe("KnowledgeBaseClient", () => {
it("handles pipeline operations", async () => {
const { client, request, requestStream } = createHttpClientWithSpies();
const kb = new KnowledgeBaseClient(client);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const form = { append: vi.fn(), getHeaders: () => ({}) };
await kb.listDatasourcePlugins("ds", { isPublished: true });
@@ -201,7 +201,6 @@ describe("KnowledgeBaseClient", () => {
});
await kb.uploadPipelineFile(form);
expect(warn).toHaveBeenCalled();
expect(request).toHaveBeenCalledWith({
method: "GET",
path: "/datasets/ds/pipeline/datasource-plugins",
@@ -246,4 +245,22 @@ describe("KnowledgeBaseClient", () => {
data: form,
});
});
it("validates form-data and optional array filters", async () => {
const { client } = createHttpClientWithSpies();
const kb = new KnowledgeBaseClient(client);
await expect(kb.createDocumentByFile("ds", {})).rejects.toBeInstanceOf(
FileUploadError
);
await expect(
kb.listSegments("ds", "doc", { status: ["ok", 1] as unknown as string[] })
).rejects.toBeInstanceOf(ValidationError);
await expect(
kb.hitTesting("ds", {
query: "q",
attachment_ids: ["att-1", 2] as unknown as string[],
})
).rejects.toBeInstanceOf(ValidationError);
});
});

View File

@@ -38,22 +38,17 @@ import {
ensureStringArray,
} from "./validation";
import { FileUploadError, ValidationError } from "../errors/dify-error";
import type { SdkFormData } from "../http/form-data";
import { isFormData } from "../http/form-data";
const warned = new Set<string>();
const warnOnce = (message: string): void => {
if (warned.has(message)) {
return;
}
warned.add(message);
console.warn(message);
};
const ensureFormData = (form: unknown, context: string): void => {
function ensureFormData(
form: unknown,
context: string
): asserts form is SdkFormData {
if (!isFormData(form)) {
throw new FileUploadError(`${context} requires FormData`);
}
};
}
const ensureNonEmptyArray = (value: unknown, name: string): void => {
if (!Array.isArray(value) || value.length === 0) {
@@ -61,12 +56,6 @@ const ensureNonEmptyArray = (value: unknown, name: string): void => {
}
};
const warnPipelineRoutes = (): void => {
warnOnce(
"RAG pipeline endpoints may be unavailable unless the service API registers dataset/rag_pipeline routes."
);
};
export class KnowledgeBaseClient extends DifyClient {
async listDatasets(
options?: DatasetListOptions
@@ -641,7 +630,6 @@ export class KnowledgeBaseClient extends DifyClient {
datasetId: string,
options?: DatasourcePluginListOptions
): Promise<DifyResponse<KnowledgeBaseResponse>> {
warnPipelineRoutes();
ensureNonEmptyString(datasetId, "datasetId");
ensureOptionalBoolean(options?.isPublished, "isPublished");
return this.http.request({
@@ -658,7 +646,6 @@ export class KnowledgeBaseClient extends DifyClient {
nodeId: string,
request: DatasourceNodeRunRequest
): Promise<DifyStream<PipelineStreamEvent>> {
warnPipelineRoutes();
ensureNonEmptyString(datasetId, "datasetId");
ensureNonEmptyString(nodeId, "nodeId");
ensureNonEmptyString(request.datasource_type, "datasource_type");
@@ -673,7 +660,6 @@ export class KnowledgeBaseClient extends DifyClient {
datasetId: string,
request: PipelineRunRequest
): Promise<DifyResponse<KnowledgeBaseResponse> | DifyStream<PipelineStreamEvent>> {
warnPipelineRoutes();
ensureNonEmptyString(datasetId, "datasetId");
ensureNonEmptyString(request.datasource_type, "datasource_type");
ensureNonEmptyString(request.start_node_id, "start_node_id");
@@ -695,7 +681,6 @@ export class KnowledgeBaseClient extends DifyClient {
async uploadPipelineFile(
form: unknown
): Promise<DifyResponse<KnowledgeBaseResponse>> {
warnPipelineRoutes();
ensureFormData(form, "uploadPipelineFile");
return this.http.request({
method: "POST",

View File

@@ -10,7 +10,7 @@ import {
validateParams,
} from "./validation";
const makeLongString = (length) => "a".repeat(length);
const makeLongString = (length: number) => "a".repeat(length);
describe("validation utilities", () => {
it("ensureNonEmptyString throws on empty or whitespace", () => {
@@ -19,9 +19,7 @@ describe("validation utilities", () => {
});
it("ensureNonEmptyString throws on overly long strings", () => {
expect(() =>
ensureNonEmptyString(makeLongString(10001), "name")
).toThrow();
expect(() => ensureNonEmptyString(makeLongString(10001), "name")).toThrow();
});
it("ensureOptionalString ignores undefined and validates when set", () => {
@@ -73,7 +71,6 @@ describe("validation utilities", () => {
expect(() => validateParams({ rating: "bad" })).toThrow();
expect(() => validateParams({ page: 1.1 })).toThrow();
expect(() => validateParams({ files: "bad" })).toThrow();
// Empty strings are allowed for optional params (e.g., keyword: "" means no filter)
expect(() => validateParams({ keyword: "" })).not.toThrow();
expect(() => validateParams({ name: makeLongString(10001) })).toThrow();
expect(() =>

View File

@@ -1,4 +1,5 @@
import { ValidationError } from "../errors/dify-error";
import { isRecord } from "../internal/type-guards";
const MAX_STRING_LENGTH = 10000;
const MAX_LIST_LENGTH = 1000;
@@ -109,8 +110,8 @@ export function validateParams(params: Record<string, unknown>): void {
`Parameter '${key}' exceeds maximum size of ${MAX_LIST_LENGTH} items`
);
}
} else if (typeof value === "object") {
if (Object.keys(value as Record<string, unknown>).length > MAX_DICT_LENGTH) {
} else if (isRecord(value)) {
if (Object.keys(value).length > MAX_DICT_LENGTH) {
throw new ValidationError(
`Parameter '${key}' exceeds maximum size of ${MAX_DICT_LENGTH} items`
);

View File

@@ -90,7 +90,6 @@ describe("WorkflowClient", () => {
const { client, request } = createHttpClientWithSpies();
const workflow = new WorkflowClient(client);
// Use createdByEndUserSessionId to filter by user session (backend API parameter)
await workflow.getLogs({
keyword: "k",
status: "succeeded",

View File

@@ -1,6 +1,12 @@
import { DifyClient } from "./base";
import type { WorkflowRunRequest, WorkflowRunResponse } from "../types/workflow";
import type { DifyResponse, DifyStream, QueryParams } from "../types/common";
import type {
DifyResponse,
DifyStream,
JsonObject,
QueryParams,
SuccessResponse,
} from "../types/common";
import {
ensureNonEmptyString,
ensureOptionalInt,
@@ -12,12 +18,12 @@ export class WorkflowClient extends DifyClient {
request: WorkflowRunRequest
): Promise<DifyResponse<WorkflowRunResponse> | DifyStream<WorkflowRunResponse>>;
run(
inputs: Record<string, unknown>,
inputs: JsonObject,
user: string,
stream?: boolean
): Promise<DifyResponse<WorkflowRunResponse> | DifyStream<WorkflowRunResponse>>;
run(
inputOrRequest: WorkflowRunRequest | Record<string, unknown>,
inputOrRequest: WorkflowRunRequest | JsonObject,
user?: string,
stream = false
): Promise<DifyResponse<WorkflowRunResponse> | DifyStream<WorkflowRunResponse>> {
@@ -30,7 +36,7 @@ export class WorkflowClient extends DifyClient {
} else {
ensureNonEmptyString(user, "user");
payload = {
inputs: inputOrRequest as Record<string, unknown>,
inputs: inputOrRequest,
user,
response_mode: stream ? "streaming" : "blocking",
};
@@ -84,10 +90,10 @@ export class WorkflowClient extends DifyClient {
stop(
taskId: string,
user: string
): Promise<DifyResponse<WorkflowRunResponse>> {
): Promise<DifyResponse<SuccessResponse>> {
ensureNonEmptyString(taskId, "taskId");
ensureNonEmptyString(user, "user");
return this.http.request<WorkflowRunResponse>({
return this.http.request<SuccessResponse>({
method: "POST",
path: `/workflows/tasks/${taskId}/stop`,
data: { user },
@@ -111,7 +117,7 @@ export class WorkflowClient extends DifyClient {
limit?: number;
startTime?: string;
endTime?: string;
}): Promise<DifyResponse<Record<string, unknown>>> {
}): Promise<DifyResponse<JsonObject>> {
if (options?.keyword) {
ensureOptionalString(options.keyword, "keyword");
}

View File

@@ -1,304 +0,0 @@
import axios from "axios";
import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
APIError,
AuthenticationError,
FileUploadError,
NetworkError,
RateLimitError,
TimeoutError,
ValidationError,
} from "../errors/dify-error";
import { HttpClient } from "./client";
describe("HttpClient", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("builds requests with auth headers and JSON content type", async () => {
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: { ok: true },
headers: { "x-request-id": "req" },
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test" });
const response = await client.request({
method: "POST",
path: "/chat-messages",
data: { user: "u" },
});
expect(response.requestId).toBe("req");
const config = mockRequest.mock.calls[0][0];
expect(config.headers.Authorization).toBe("Bearer test");
expect(config.headers["Content-Type"]).toBe("application/json");
expect(config.responseType).toBe("json");
});
it("serializes array query params", async () => {
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: "ok",
headers: {},
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test" });
await client.requestRaw({
method: "GET",
path: "/datasets",
query: { tag_ids: ["a", "b"], limit: 2 },
});
const config = mockRequest.mock.calls[0][0];
const queryString = config.paramsSerializer.serialize({
tag_ids: ["a", "b"],
limit: 2,
});
expect(queryString).toBe("tag_ids=a&tag_ids=b&limit=2");
});
it("returns SSE stream helpers", async () => {
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: Readable.from(["data: {\"text\":\"hi\"}\n\n"]),
headers: { "x-request-id": "req" },
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test" });
const stream = await client.requestStream({
method: "POST",
path: "/chat-messages",
data: { user: "u" },
});
expect(stream.status).toBe(200);
expect(stream.requestId).toBe("req");
await expect(stream.toText()).resolves.toBe("hi");
});
it("returns binary stream helpers", async () => {
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: Readable.from(["chunk"]),
headers: { "x-request-id": "req" },
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test" });
const stream = await client.requestBinaryStream({
method: "POST",
path: "/text-to-audio",
data: { user: "u", text: "hi" },
});
expect(stream.status).toBe(200);
expect(stream.requestId).toBe("req");
});
it("respects form-data headers", async () => {
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: "ok",
headers: {},
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test" });
const form = {
append: () => {},
getHeaders: () => ({ "content-type": "multipart/form-data; boundary=abc" }),
};
await client.requestRaw({
method: "POST",
path: "/files/upload",
data: form,
});
const config = mockRequest.mock.calls[0][0];
expect(config.headers["content-type"]).toBe(
"multipart/form-data; boundary=abc"
);
expect(config.headers["Content-Type"]).toBeUndefined();
});
it("maps 401 and 429 errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: {
status: 401,
data: { message: "unauthorized" },
headers: {},
},
});
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(AuthenticationError);
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: {
status: 429,
data: { message: "rate" },
headers: { "retry-after": "2" },
},
});
const error = await client
.requestRaw({ method: "GET", path: "/meta" })
.catch((err) => err);
expect(error).toBeInstanceOf(RateLimitError);
expect(error.retryAfter).toBe(2);
});
it("maps validation and upload errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: {
status: 422,
data: { message: "invalid" },
headers: {},
},
});
await expect(
client.requestRaw({ method: "POST", path: "/chat-messages", data: { user: "u" } })
).rejects.toBeInstanceOf(ValidationError);
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
config: { url: "/files/upload" },
response: {
status: 400,
data: { message: "bad upload" },
headers: {},
},
});
await expect(
client.requestRaw({ method: "POST", path: "/files/upload", data: { user: "u" } })
).rejects.toBeInstanceOf(FileUploadError);
});
it("maps timeout and network errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
code: "ECONNABORTED",
message: "timeout",
});
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(TimeoutError);
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
message: "network",
});
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(NetworkError);
});
it("retries on timeout errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 1, retryDelay: 0 });
mockRequest
.mockRejectedValueOnce({
isAxiosError: true,
code: "ECONNABORTED",
message: "timeout",
})
.mockResolvedValueOnce({ status: 200, data: "ok", headers: {} });
await client.requestRaw({ method: "GET", path: "/meta" });
expect(mockRequest).toHaveBeenCalledTimes(2);
});
it("validates query parameters before request", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test" });
await expect(
client.requestRaw({ method: "GET", path: "/meta", query: { user: 1 } })
).rejects.toBeInstanceOf(ValidationError);
expect(mockRequest).not.toHaveBeenCalled();
});
it("returns APIError for other http failures", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500, data: { message: "server" }, headers: {} },
});
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(APIError);
});
it("logs requests and responses when enableLogging is true", async () => {
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: { ok: true },
headers: {},
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const client = new HttpClient({ apiKey: "test", enableLogging: true });
await client.requestRaw({ method: "GET", path: "/meta" });
expect(consoleInfo).toHaveBeenCalledWith(
expect.stringContaining("dify-client-node response 200 GET")
);
consoleInfo.mockRestore();
});
it("logs retry attempts when enableLogging is true", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const client = new HttpClient({
apiKey: "test",
maxRetries: 1,
retryDelay: 0,
enableLogging: true,
});
mockRequest
.mockRejectedValueOnce({
isAxiosError: true,
code: "ECONNABORTED",
message: "timeout",
})
.mockResolvedValueOnce({ status: 200, data: "ok", headers: {} });
await client.requestRaw({ method: "GET", path: "/meta" });
expect(consoleInfo).toHaveBeenCalledWith(
expect.stringContaining("dify-client-node retry")
);
consoleInfo.mockRestore();
});
});

View File

@@ -0,0 +1,527 @@
import { Readable, Stream } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
APIError,
AuthenticationError,
FileUploadError,
NetworkError,
RateLimitError,
TimeoutError,
ValidationError,
} from "../errors/dify-error";
import { HttpClient } from "./client";
const stubFetch = (): ReturnType<typeof vi.fn> => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
};
const getFetchCall = (
fetchMock: ReturnType<typeof vi.fn>,
index = 0
): [string, RequestInit | undefined] => {
const call = fetchMock.mock.calls[index];
if (!call) {
throw new Error(`Missing fetch call at index ${index}`);
}
return call as [string, RequestInit | undefined];
};
const toHeaderRecord = (headers: HeadersInit | undefined): Record<string, string> =>
Object.fromEntries(new Headers(headers).entries());
const jsonResponse = (
body: unknown,
init: ResponseInit = {}
): Response =>
new Response(JSON.stringify(body), {
...init,
headers: {
"content-type": "application/json",
...(init.headers ?? {}),
},
});
const textResponse = (body: string, init: ResponseInit = {}): Response =>
new Response(body, {
...init,
headers: {
...(init.headers ?? {}),
},
});
describe("HttpClient", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("builds requests with auth headers and JSON content type", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
jsonResponse({ ok: true }, { status: 200, headers: { "x-request-id": "req" } })
);
const client = new HttpClient({ apiKey: "test" });
const response = await client.request({
method: "POST",
path: "/chat-messages",
data: { user: "u" },
});
expect(response.requestId).toBe("req");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = getFetchCall(fetchMock);
expect(url).toBe("https://api.dify.ai/v1/chat-messages");
expect(toHeaderRecord(init?.headers)).toMatchObject({
authorization: "Bearer test",
"content-type": "application/json",
"user-agent": "dify-client-node",
});
expect(init?.body).toBe(JSON.stringify({ user: "u" }));
});
it("serializes array query params", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 }));
const client = new HttpClient({ apiKey: "test" });
await client.requestRaw({
method: "GET",
path: "/datasets",
query: { tag_ids: ["a", "b"], limit: 2 },
});
const [url] = getFetchCall(fetchMock);
expect(new URL(url).searchParams.toString()).toBe(
"tag_ids=a&tag_ids=b&limit=2"
);
});
it("returns SSE stream helpers", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
new Response('data: {"text":"hi"}\n\n', {
status: 200,
headers: { "x-request-id": "req" },
})
);
const client = new HttpClient({ apiKey: "test" });
const stream = await client.requestStream({
method: "POST",
path: "/chat-messages",
data: { user: "u" },
});
expect(stream.status).toBe(200);
expect(stream.requestId).toBe("req");
await expect(stream.toText()).resolves.toBe("hi");
});
it("returns binary stream helpers", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
new Response("chunk", {
status: 200,
headers: { "x-request-id": "req" },
})
);
const client = new HttpClient({ apiKey: "test" });
const stream = await client.requestBinaryStream({
method: "POST",
path: "/text-to-audio",
data: { user: "u", text: "hi" },
});
expect(stream.status).toBe(200);
expect(stream.requestId).toBe("req");
expect(stream.data).toBeInstanceOf(Readable);
});
it("respects form-data headers", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 }));
const client = new HttpClient({ apiKey: "test" });
const form = new FormData();
form.append("file", new Blob(["abc"]), "file.txt");
await client.requestRaw({
method: "POST",
path: "/files/upload",
data: form,
});
const [, init] = getFetchCall(fetchMock);
expect(toHeaderRecord(init?.headers)).toMatchObject({
authorization: "Bearer test",
});
expect(toHeaderRecord(init?.headers)["content-type"]).toBeUndefined();
});
it("sends legacy form-data as a readable request body", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 }));
const client = new HttpClient({ apiKey: "test" });
const legacyForm = Object.assign(Readable.from(["chunk"]), {
append: vi.fn(),
getHeaders: () => ({
"content-type": "multipart/form-data; boundary=test",
}),
});
await client.requestRaw({
method: "POST",
path: "/files/upload",
data: legacyForm,
});
const [, init] = getFetchCall(fetchMock);
expect(toHeaderRecord(init?.headers)).toMatchObject({
authorization: "Bearer test",
"content-type": "multipart/form-data; boundary=test",
});
expect((init as RequestInit & { duplex?: string } | undefined)?.duplex).toBe(
"half"
);
expect(init?.body).not.toBe(legacyForm);
});
it("rejects legacy form-data objects that are not readable streams", async () => {
const fetchMock = stubFetch();
const client = new HttpClient({ apiKey: "test" });
const legacyForm = {
append: vi.fn(),
getHeaders: () => ({
"content-type": "multipart/form-data; boundary=test",
}),
};
await expect(
client.requestRaw({
method: "POST",
path: "/files/upload",
data: legacyForm,
})
).rejects.toBeInstanceOf(FileUploadError);
expect(fetchMock).not.toHaveBeenCalled();
});
it("accepts legacy pipeable streams that are not Readable instances", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 }));
const client = new HttpClient({ apiKey: "test" });
const legacyStream = new Stream() as Stream &
NodeJS.ReadableStream & {
append: ReturnType<typeof vi.fn>;
getHeaders: () => Record<string, string>;
};
legacyStream.readable = true;
legacyStream.pause = () => legacyStream;
legacyStream.resume = () => legacyStream;
legacyStream.append = vi.fn();
legacyStream.getHeaders = () => ({
"content-type": "multipart/form-data; boundary=test",
});
queueMicrotask(() => {
legacyStream.emit("data", Buffer.from("chunk"));
legacyStream.emit("end");
});
await client.requestRaw({
method: "POST",
path: "/files/upload",
data: legacyStream as unknown as FormData,
});
const [, init] = getFetchCall(fetchMock);
expect((init as RequestInit & { duplex?: string } | undefined)?.duplex).toBe(
"half"
);
});
it("returns buffers for byte responses", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
new Response(Uint8Array.from([1, 2, 3]), {
status: 200,
headers: { "content-type": "application/octet-stream" },
})
);
const client = new HttpClient({ apiKey: "test" });
const response = await client.request<Buffer, "bytes">({
method: "GET",
path: "/files/file-1/preview",
responseType: "bytes",
});
expect(Buffer.isBuffer(response.data)).toBe(true);
expect(Array.from(response.data.values())).toEqual([1, 2, 3]);
});
it("keeps arraybuffer as a backward-compatible binary alias", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
new Response(Uint8Array.from([4, 5, 6]), {
status: 200,
headers: { "content-type": "application/octet-stream" },
})
);
const client = new HttpClient({ apiKey: "test" });
const response = await client.request<Buffer, "arraybuffer">({
method: "GET",
path: "/files/file-1/preview",
responseType: "arraybuffer",
});
expect(Buffer.isBuffer(response.data)).toBe(true);
expect(Array.from(response.data.values())).toEqual([4, 5, 6]);
});
it("returns null for empty no-content responses", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
const client = new HttpClient({ apiKey: "test" });
const response = await client.requestRaw({
method: "GET",
path: "/meta",
});
expect(response.data).toBeNull();
});
it("maps 401 and 429 errors", async () => {
const fetchMock = stubFetch();
fetchMock
.mockResolvedValueOnce(
jsonResponse({ message: "unauthorized" }, { status: 401 })
)
.mockResolvedValueOnce(
jsonResponse({ message: "rate" }, { status: 429, headers: { "retry-after": "2" } })
);
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(AuthenticationError);
const error = await client
.requestRaw({ method: "GET", path: "/meta" })
.catch((err: unknown) => err);
expect(error).toBeInstanceOf(RateLimitError);
expect((error as RateLimitError).retryAfter).toBe(2);
});
it("maps validation and upload errors", async () => {
const fetchMock = stubFetch();
fetchMock
.mockResolvedValueOnce(jsonResponse({ message: "invalid" }, { status: 422 }))
.mockResolvedValueOnce(jsonResponse({ message: "bad upload" }, { status: 400 }));
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
await expect(
client.requestRaw({ method: "POST", path: "/chat-messages", data: { user: "u" } })
).rejects.toBeInstanceOf(ValidationError);
await expect(
client.requestRaw({ method: "POST", path: "/files/upload", data: { user: "u" } })
).rejects.toBeInstanceOf(FileUploadError);
});
it("maps timeout and network errors", async () => {
const fetchMock = stubFetch();
fetchMock
.mockRejectedValueOnce(Object.assign(new Error("timeout"), { name: "AbortError" }))
.mockRejectedValueOnce(new Error("network"));
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(TimeoutError);
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(NetworkError);
});
it("maps unknown transport failures to NetworkError", async () => {
const fetchMock = stubFetch();
fetchMock.mockRejectedValueOnce("boom");
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toMatchObject({
name: "NetworkError",
message: "Unexpected network error",
});
});
it("retries on timeout errors", async () => {
const fetchMock = stubFetch();
fetchMock
.mockRejectedValueOnce(Object.assign(new Error("timeout"), { name: "AbortError" }))
.mockResolvedValueOnce(jsonResponse("ok", { status: 200 }));
const client = new HttpClient({ apiKey: "test", maxRetries: 1, retryDelay: 0 });
await client.requestRaw({ method: "GET", path: "/meta" });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not retry non-replayable readable request bodies", async () => {
const fetchMock = stubFetch();
fetchMock.mockRejectedValueOnce(new Error("network"));
const client = new HttpClient({ apiKey: "test", maxRetries: 2, retryDelay: 0 });
await expect(
client.requestRaw({
method: "POST",
path: "/chat-messages",
data: Readable.from(["chunk"]),
})
).rejects.toBeInstanceOf(NetworkError);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = getFetchCall(fetchMock);
expect((init as RequestInit & { duplex?: string } | undefined)?.duplex).toBe(
"half"
);
});
it("validates query parameters before request", async () => {
const fetchMock = stubFetch();
const client = new HttpClient({ apiKey: "test" });
await expect(
client.requestRaw({ method: "GET", path: "/meta", query: { user: 1 } })
).rejects.toBeInstanceOf(ValidationError);
expect(fetchMock).not.toHaveBeenCalled();
});
it("returns APIError for other http failures", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse({ message: "server" }, { status: 500 }));
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(APIError);
});
it("uses plain text bodies when json parsing is not possible", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
textResponse("plain text", {
status: 200,
headers: { "content-type": "text/plain" },
})
);
const client = new HttpClient({ apiKey: "test" });
const response = await client.requestRaw({
method: "GET",
path: "/info",
});
expect(response.data).toBe("plain text");
});
it("keeps invalid json error bodies as API errors", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
textResponse("{invalid", {
status: 500,
headers: { "content-type": "application/json", "x-request-id": "req-500" },
})
);
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toMatchObject({
name: "APIError",
statusCode: 500,
requestId: "req-500",
responseBody: "{invalid",
});
});
it("sends raw string bodies without additional json encoding", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 }));
const client = new HttpClient({ apiKey: "test" });
await client.requestRaw({
method: "POST",
path: "/meta",
data: '{"pre":"serialized"}',
headers: { "Content-Type": "application/custom+json" },
});
const [, init] = getFetchCall(fetchMock);
expect(init?.body).toBe('{"pre":"serialized"}');
expect(toHeaderRecord(init?.headers)).toMatchObject({
"content-type": "application/custom+json",
});
});
it("preserves explicit user-agent headers", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }, { status: 200 }));
const client = new HttpClient({ apiKey: "test" });
await client.requestRaw({
method: "GET",
path: "/meta",
headers: { "User-Agent": "custom-agent" },
});
const [, init] = getFetchCall(fetchMock);
expect(toHeaderRecord(init?.headers)).toMatchObject({
"user-agent": "custom-agent",
});
});
it("logs requests and responses when enableLogging is true", async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }, { status: 200 }));
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const client = new HttpClient({ apiKey: "test", enableLogging: true });
await client.requestRaw({ method: "GET", path: "/meta" });
expect(consoleInfo).toHaveBeenCalledWith(
expect.stringContaining("dify-client-node response 200 GET")
);
});
it("logs retry attempts when enableLogging is true", async () => {
const fetchMock = stubFetch();
fetchMock
.mockRejectedValueOnce(Object.assign(new Error("timeout"), { name: "AbortError" }))
.mockResolvedValueOnce(jsonResponse("ok", { status: 200 }));
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const client = new HttpClient({
apiKey: "test",
maxRetries: 1,
retryDelay: 0,
enableLogging: true,
});
await client.requestRaw({ method: "GET", path: "/meta" });
expect(consoleInfo).toHaveBeenCalledWith(
expect.stringContaining("dify-client-node retry")
);
});
});

View File

@@ -1,11 +1,4 @@
import axios from "axios";
import type {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from "axios";
import type { Readable } from "node:stream";
import { Readable } from "node:stream";
import {
DEFAULT_BASE_URL,
DEFAULT_MAX_RETRIES,
@@ -13,36 +6,69 @@ import {
DEFAULT_TIMEOUT_SECONDS,
} from "../types/common";
import type {
BinaryStream,
DifyClientConfig,
DifyResponse,
DifyStream,
Headers,
JsonValue,
QueryParams,
RequestMethod,
} from "../types/common";
import type { DifyError } from "../errors/dify-error";
import {
APIError,
AuthenticationError,
DifyError,
FileUploadError,
NetworkError,
RateLimitError,
TimeoutError,
ValidationError,
} from "../errors/dify-error";
import type { SdkFormData } from "./form-data";
import { getFormDataHeaders, isFormData } from "./form-data";
import { createBinaryStream, createSseStream } from "./sse";
import { getRetryDelayMs, shouldRetry, sleep } from "./retry";
import { validateParams } from "../client/validation";
import { hasStringProperty, isRecord } from "../internal/type-guards";
const DEFAULT_USER_AGENT = "dify-client-node";
export type RequestOptions = {
export type HttpResponseType = "json" | "bytes" | "stream" | "arraybuffer";
export type HttpRequestBody =
| JsonValue
| Readable
| SdkFormData
| URLSearchParams
| ArrayBuffer
| ArrayBufferView
| Blob
| string
| null;
export type ResponseDataFor<TResponseType extends HttpResponseType> =
TResponseType extends "stream"
? Readable
: TResponseType extends "bytes" | "arraybuffer"
? Buffer
: JsonValue | string | null;
export type RawHttpResponse<TData = unknown> = {
data: TData;
status: number;
headers: Headers;
requestId?: string;
url: string;
};
export type RequestOptions<TResponseType extends HttpResponseType = "json"> = {
method: RequestMethod;
path: string;
query?: QueryParams;
data?: unknown;
data?: HttpRequestBody;
headers?: Headers;
responseType?: AxiosRequestConfig["responseType"];
responseType?: TResponseType;
};
export type HttpClientSettings = Required<
@@ -51,6 +77,23 @@ export type HttpClientSettings = Required<
apiKey: string;
};
type FetchRequestInit = RequestInit & {
duplex?: "half";
};
type PreparedRequestBody = {
body?: BodyInit | null;
headers: Headers;
duplex?: "half";
replayable: boolean;
};
type TimeoutContext = {
cleanup: () => void;
reason: Error;
signal: AbortSignal;
};
const normalizeSettings = (config: DifyClientConfig): HttpClientSettings => ({
apiKey: config.apiKey,
baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
@@ -60,19 +103,10 @@ const normalizeSettings = (config: DifyClientConfig): HttpClientSettings => ({
enableLogging: config.enableLogging ?? false,
});
const normalizeHeaders = (headers: AxiosResponse["headers"]): Headers => {
const normalizeHeaders = (headers: globalThis.Headers): Headers => {
const result: Headers = {};
if (!headers) {
return result;
}
Object.entries(headers).forEach(([key, value]) => {
if (Array.isArray(value)) {
result[key.toLowerCase()] = value.join(", ");
} else if (typeof value === "string") {
result[key.toLowerCase()] = value;
} else if (typeof value === "number") {
result[key.toLowerCase()] = value.toString();
}
headers.forEach((value, key) => {
result[key.toLowerCase()] = value;
});
return result;
};
@@ -80,9 +114,18 @@ const normalizeHeaders = (headers: AxiosResponse["headers"]): Headers => {
const resolveRequestId = (headers: Headers): string | undefined =>
headers["x-request-id"] ?? headers["x-requestid"];
const buildRequestUrl = (baseUrl: string, path: string): string => {
const buildRequestUrl = (
baseUrl: string,
path: string,
query?: QueryParams
): string => {
const trimmed = baseUrl.replace(/\/+$/, "");
return `${trimmed}${path}`;
const url = new URL(`${trimmed}${path}`);
const queryString = buildQueryString(query);
if (queryString) {
url.search = queryString;
}
return url.toString();
};
const buildQueryString = (params?: QueryParams): string => {
@@ -121,24 +164,53 @@ const parseRetryAfterSeconds = (headerValue?: string): number | undefined => {
return undefined;
};
const isReadableStream = (value: unknown): value is Readable => {
const isPipeableStream = (value: unknown): value is { pipe: (destination: unknown) => unknown } => {
if (!value || typeof value !== "object") {
return false;
}
return typeof (value as { pipe?: unknown }).pipe === "function";
};
const isUploadLikeRequest = (config?: AxiosRequestConfig): boolean => {
const url = (config?.url ?? "").toLowerCase();
if (!url) {
return false;
const toNodeReadable = (value: unknown): Readable | null => {
if (value instanceof Readable) {
return value;
}
if (!isPipeableStream(value)) {
return null;
}
const readable = new Readable({
read() {},
});
return readable.wrap(value as NodeJS.ReadableStream);
};
const isBinaryBody = (
value: unknown
): value is ArrayBuffer | ArrayBufferView | Blob => {
if (value instanceof Blob) {
return true;
}
if (value instanceof ArrayBuffer) {
return true;
}
return ArrayBuffer.isView(value);
};
const isJsonBody = (value: unknown): value is Exclude<JsonValue, string> =>
value === null ||
typeof value === "boolean" ||
typeof value === "number" ||
Array.isArray(value) ||
isRecord(value);
const isUploadLikeRequest = (path: string): boolean => {
const normalizedPath = path.toLowerCase();
return (
url.includes("upload") ||
url.includes("/files/") ||
url.includes("audio-to-text") ||
url.includes("create_by_file") ||
url.includes("update_by_file")
normalizedPath.includes("upload") ||
normalizedPath.includes("/files/") ||
normalizedPath.includes("audio-to-text") ||
normalizedPath.includes("create_by_file") ||
normalizedPath.includes("update_by_file")
);
};
@@ -146,88 +218,242 @@ const resolveErrorMessage = (status: number, responseBody: unknown): string => {
if (typeof responseBody === "string" && responseBody.trim().length > 0) {
return responseBody;
}
if (
responseBody &&
typeof responseBody === "object" &&
"message" in responseBody
) {
const message = (responseBody as Record<string, unknown>).message;
if (typeof message === "string" && message.trim().length > 0) {
if (hasStringProperty(responseBody, "message")) {
const message = responseBody.message.trim();
if (message.length > 0) {
return message;
}
}
return `Request failed with status code ${status}`;
};
const mapAxiosError = (error: unknown): DifyError => {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.response) {
const status = axiosError.response.status;
const headers = normalizeHeaders(axiosError.response.headers);
const requestId = resolveRequestId(headers);
const responseBody = axiosError.response.data;
const message = resolveErrorMessage(status, responseBody);
if (status === 401) {
return new AuthenticationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (status === 429) {
const retryAfter = parseRetryAfterSeconds(headers["retry-after"]);
return new RateLimitError(message, {
statusCode: status,
responseBody,
requestId,
retryAfter,
});
}
if (status === 422) {
return new ValidationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (status === 400) {
if (isUploadLikeRequest(axiosError.config)) {
return new FileUploadError(message, {
statusCode: status,
responseBody,
requestId,
});
}
}
return new APIError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (axiosError.code === "ECONNABORTED") {
return new TimeoutError("Request timed out", { cause: axiosError });
}
return new NetworkError(axiosError.message, { cause: axiosError });
const parseJsonLikeText = (
value: string,
contentType?: string | null
): JsonValue | string | null => {
if (value.length === 0) {
return null;
}
const shouldParseJson =
contentType?.includes("application/json") === true ||
contentType?.includes("+json") === true;
if (!shouldParseJson) {
try {
return JSON.parse(value) as JsonValue;
} catch {
return value;
}
}
return JSON.parse(value) as JsonValue;
};
const prepareRequestBody = (
method: RequestMethod,
data: HttpRequestBody | undefined
): PreparedRequestBody => {
if (method === "GET" || data === undefined) {
return {
body: undefined,
headers: {},
replayable: true,
};
}
if (isFormData(data)) {
if ("getHeaders" in data && typeof data.getHeaders === "function") {
const readable = toNodeReadable(data);
if (!readable) {
throw new FileUploadError(
"Legacy FormData must be a readable stream when used with fetch"
);
}
return {
body: Readable.toWeb(readable) as BodyInit,
headers: getFormDataHeaders(data),
duplex: "half",
replayable: false,
};
}
return {
body: data as BodyInit,
headers: getFormDataHeaders(data),
replayable: true,
};
}
if (typeof data === "string") {
return {
body: data,
headers: {},
replayable: true,
};
}
const readable = toNodeReadable(data);
if (readable) {
return {
body: Readable.toWeb(readable) as BodyInit,
headers: {},
duplex: "half",
replayable: false,
};
}
if (data instanceof URLSearchParams || isBinaryBody(data)) {
const body =
ArrayBuffer.isView(data) && !(data instanceof Uint8Array)
? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
: data;
return {
body: body as BodyInit,
headers: {},
replayable: true,
};
}
if (isJsonBody(data)) {
return {
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json",
},
replayable: true,
};
}
throw new ValidationError("Unsupported request body type");
};
const createTimeoutContext = (timeoutMs: number): TimeoutContext => {
const controller = new AbortController();
const reason = new Error("Request timed out");
const timer = setTimeout(() => {
controller.abort(reason);
}, timeoutMs);
return {
signal: controller.signal,
reason,
cleanup: () => {
clearTimeout(timer);
},
};
};
const parseResponseBody = async <TResponseType extends HttpResponseType>(
response: Response,
responseType: TResponseType
): Promise<ResponseDataFor<TResponseType>> => {
if (responseType === "stream") {
if (!response.body) {
throw new NetworkError("Response body is empty");
}
return Readable.fromWeb(
response.body as unknown as Parameters<typeof Readable.fromWeb>[0]
) as ResponseDataFor<TResponseType>;
}
if (responseType === "bytes" || responseType === "arraybuffer") {
const bytes = Buffer.from(await response.arrayBuffer());
return bytes as ResponseDataFor<TResponseType>;
}
if (response.status === 204 || response.status === 205 || response.status === 304) {
return null as ResponseDataFor<TResponseType>;
}
const text = await response.text();
try {
return parseJsonLikeText(
text,
response.headers.get("content-type")
) as ResponseDataFor<TResponseType>;
} catch (error) {
if (!response.ok && error instanceof SyntaxError) {
return text as ResponseDataFor<TResponseType>;
}
throw error;
}
};
const mapHttpError = (
response: RawHttpResponse,
path: string
): DifyError => {
const status = response.status;
const responseBody = response.data;
const message = resolveErrorMessage(status, responseBody);
if (status === 401) {
return new AuthenticationError(message, {
statusCode: status,
responseBody,
requestId: response.requestId,
});
}
if (status === 429) {
const retryAfter = parseRetryAfterSeconds(response.headers["retry-after"]);
return new RateLimitError(message, {
statusCode: status,
responseBody,
requestId: response.requestId,
retryAfter,
});
}
if (status === 422) {
return new ValidationError(message, {
statusCode: status,
responseBody,
requestId: response.requestId,
});
}
if (status === 400 && isUploadLikeRequest(path)) {
return new FileUploadError(message, {
statusCode: status,
responseBody,
requestId: response.requestId,
});
}
return new APIError(message, {
statusCode: status,
responseBody,
requestId: response.requestId,
});
};
const mapTransportError = (
error: unknown,
timeoutContext: TimeoutContext
): DifyError => {
if (error instanceof DifyError) {
return error;
}
if (
timeoutContext.signal.aborted &&
timeoutContext.signal.reason === timeoutContext.reason
) {
return new TimeoutError("Request timed out", { cause: error });
}
if (error instanceof Error) {
if (error.name === "AbortError" || error.name === "TimeoutError") {
return new TimeoutError("Request timed out", { cause: error });
}
return new NetworkError(error.message, { cause: error });
}
return new NetworkError("Unexpected network error", { cause: error });
};
export class HttpClient {
private axios: AxiosInstance;
private settings: HttpClientSettings;
constructor(config: DifyClientConfig) {
this.settings = normalizeSettings(config);
this.axios = axios.create({
baseURL: this.settings.baseUrl,
timeout: this.settings.timeout * 1000,
});
}
updateApiKey(apiKey: string): void {
@@ -238,118 +464,123 @@ export class HttpClient {
return { ...this.settings };
}
async request<T>(options: RequestOptions): Promise<DifyResponse<T>> {
async request<
T,
TResponseType extends HttpResponseType = "json",
>(options: RequestOptions<TResponseType>): Promise<DifyResponse<T>> {
const response = await this.requestRaw(options);
const headers = normalizeHeaders(response.headers);
return {
data: response.data as T,
status: response.status,
headers,
requestId: resolveRequestId(headers),
headers: response.headers,
requestId: response.requestId,
};
}
async requestStream<T>(options: RequestOptions) {
async requestStream<T>(options: RequestOptions): Promise<DifyStream<T>> {
const response = await this.requestRaw({
...options,
responseType: "stream",
});
const headers = normalizeHeaders(response.headers);
return createSseStream<T>(response.data as Readable, {
return createSseStream<T>(response.data, {
status: response.status,
headers,
requestId: resolveRequestId(headers),
headers: response.headers,
requestId: response.requestId,
});
}
async requestBinaryStream(options: RequestOptions) {
async requestBinaryStream(options: RequestOptions): Promise<BinaryStream> {
const response = await this.requestRaw({
...options,
responseType: "stream",
});
const headers = normalizeHeaders(response.headers);
return createBinaryStream(response.data as Readable, {
return createBinaryStream(response.data, {
status: response.status,
headers,
requestId: resolveRequestId(headers),
headers: response.headers,
requestId: response.requestId,
});
}
async requestRaw(options: RequestOptions): Promise<AxiosResponse> {
const { method, path, query, data, headers, responseType } = options;
const { apiKey, enableLogging, maxRetries, retryDelay, timeout } =
this.settings;
async requestRaw<TResponseType extends HttpResponseType = "json">(
options: RequestOptions<TResponseType>
): Promise<RawHttpResponse<ResponseDataFor<TResponseType>>> {
const responseType = options.responseType ?? "json";
const { method, path, query, data, headers } = options;
const { apiKey, enableLogging, maxRetries, retryDelay, timeout } = this.settings;
if (query) {
validateParams(query as Record<string, unknown>);
}
if (
data &&
typeof data === "object" &&
!Array.isArray(data) &&
!isFormData(data) &&
!isReadableStream(data)
) {
validateParams(data as Record<string, unknown>);
if (isRecord(data) && !Array.isArray(data) && !isFormData(data) && !isPipeableStream(data)) {
validateParams(data);
}
const requestHeaders: Headers = {
Authorization: `Bearer ${apiKey}`,
...headers,
};
if (
typeof process !== "undefined" &&
!!process.versions?.node &&
!requestHeaders["User-Agent"] &&
!requestHeaders["user-agent"]
) {
requestHeaders["User-Agent"] = DEFAULT_USER_AGENT;
}
if (isFormData(data)) {
Object.assign(requestHeaders, getFormDataHeaders(data));
} else if (data && method !== "GET") {
requestHeaders["Content-Type"] = "application/json";
}
const url = buildRequestUrl(this.settings.baseUrl, path);
const url = buildRequestUrl(this.settings.baseUrl, path, query);
if (enableLogging) {
console.info(`dify-client-node request ${method} ${url}`);
}
const axiosConfig: AxiosRequestConfig = {
method,
url: path,
params: query,
paramsSerializer: {
serialize: (params) => buildQueryString(params as QueryParams),
},
headers: requestHeaders,
responseType: responseType ?? "json",
timeout: timeout * 1000,
};
if (method !== "GET" && data !== undefined) {
axiosConfig.data = data;
}
let attempt = 0;
// `attempt` is a zero-based retry counter
// Total attempts = 1 (initial) + maxRetries
// e.g., maxRetries=3 means: attempt 0 (initial), then retries at 1, 2, 3
while (true) {
const preparedBody = prepareRequestBody(method, data);
const requestHeaders: Headers = {
Authorization: `Bearer ${apiKey}`,
...preparedBody.headers,
...headers,
};
if (
typeof process !== "undefined" &&
!!process.versions?.node &&
!requestHeaders["User-Agent"] &&
!requestHeaders["user-agent"]
) {
requestHeaders["User-Agent"] = DEFAULT_USER_AGENT;
}
const timeoutContext = createTimeoutContext(timeout * 1000);
const requestInit: FetchRequestInit = {
method,
headers: requestHeaders,
body: preparedBody.body,
signal: timeoutContext.signal,
};
if (preparedBody.duplex) {
requestInit.duplex = preparedBody.duplex;
}
try {
const response = await this.axios.request(axiosConfig);
const fetchResponse = await fetch(url, requestInit);
const responseHeaders = normalizeHeaders(fetchResponse.headers);
const parsedBody =
(await parseResponseBody(fetchResponse, responseType)) as ResponseDataFor<TResponseType>;
const response: RawHttpResponse<ResponseDataFor<TResponseType>> = {
data: parsedBody,
status: fetchResponse.status,
headers: responseHeaders,
requestId: resolveRequestId(responseHeaders),
url,
};
if (!fetchResponse.ok) {
throw mapHttpError(response, path);
}
if (enableLogging) {
console.info(
`dify-client-node response ${response.status} ${method} ${url}`
);
}
return response;
} catch (error) {
const mapped = mapAxiosError(error);
if (!shouldRetry(mapped, attempt, maxRetries)) {
const mapped = mapTransportError(error, timeoutContext);
const shouldRetryRequest =
preparedBody.replayable && shouldRetry(mapped, attempt, maxRetries);
if (!shouldRetryRequest) {
throw mapped;
}
const retryAfterSeconds =
@@ -362,6 +593,8 @@ export class HttpClient {
}
attempt += 1;
await sleep(delay);
} finally {
timeoutContext.cleanup();
}
}
}

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { getFormDataHeaders, isFormData } from "./form-data";
describe("form-data helpers", () => {
@@ -11,9 +11,15 @@ describe("form-data helpers", () => {
expect(isFormData({})).toBe(false);
});
it("detects native FormData", () => {
const form = new FormData();
form.append("field", "value");
expect(isFormData(form)).toBe(true);
});
it("returns headers from form-data", () => {
const formLike = {
append: () => {},
append: vi.fn(),
getHeaders: () => ({ "content-type": "multipart/form-data" }),
};
expect(getFormDataHeaders(formLike)).toEqual({

View File

@@ -1,19 +1,25 @@
import type { Headers } from "../types/common";
export type FormDataLike = {
append: (...args: unknown[]) => void;
getHeaders?: () => Headers;
type FormDataAppendValue = Blob | string;
export type WebFormData = FormData;
export type LegacyNodeFormData = {
append: (name: string, value: FormDataAppendValue, fileName?: string) => void;
getHeaders: () => Headers;
constructor?: { name?: string };
};
export const isFormData = (value: unknown): value is FormDataLike => {
export type SdkFormData = WebFormData | LegacyNodeFormData;
export const isFormData = (value: unknown): value is SdkFormData => {
if (!value || typeof value !== "object") {
return false;
}
if (typeof FormData !== "undefined" && value instanceof FormData) {
return true;
}
const candidate = value as FormDataLike;
const candidate = value as Partial<LegacyNodeFormData>;
if (typeof candidate.append !== "function") {
return false;
}
@@ -23,8 +29,8 @@ export const isFormData = (value: unknown): value is FormDataLike => {
return candidate.constructor?.name === "FormData";
};
export const getFormDataHeaders = (form: FormDataLike): Headers => {
if (typeof form.getHeaders === "function") {
export const getFormDataHeaders = (form: SdkFormData): Headers => {
if ("getHeaders" in form && typeof form.getHeaders === "function") {
return form.getHeaders();
}
return {};

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { getRetryDelayMs, shouldRetry } from "./retry";
import { NetworkError, RateLimitError, TimeoutError } from "../errors/dify-error";
const withMockedRandom = (value, fn) => {
const withMockedRandom = (value: number, fn: () => void): void => {
const original = Math.random;
Math.random = () => value;
try {

View File

@@ -6,10 +6,10 @@ describe("sse parsing", () => {
it("parses event and data lines", async () => {
const stream = Readable.from([
"event: message\n",
"data: {\"answer\":\"hi\"}\n",
'data: {"answer":"hi"}\n',
"\n",
]);
const events = [];
const events: Array<{ event?: string; data: unknown; raw: string }> = [];
for await (const event of parseSseStream(stream)) {
events.push(event);
}
@@ -20,7 +20,7 @@ describe("sse parsing", () => {
it("handles multi-line data payloads", async () => {
const stream = Readable.from(["data: line1\n", "data: line2\n", "\n"]);
const events = [];
const events: Array<{ event?: string; data: unknown; raw: string }> = [];
for await (const event of parseSseStream(stream)) {
events.push(event);
}
@@ -28,10 +28,28 @@ describe("sse parsing", () => {
expect(events[0].data).toBe("line1\nline2");
});
it("ignores comments and flushes the last event without a trailing separator", async () => {
const stream = Readable.from([
Buffer.from(": keep-alive\n"),
Uint8Array.from(Buffer.from('event: message\ndata: {"delta":"hi"}\n')),
]);
const events: Array<{ event?: string; data: unknown; raw: string }> = [];
for await (const event of parseSseStream(stream)) {
events.push(event);
}
expect(events).toEqual([
{
event: "message",
data: { delta: "hi" },
raw: '{"delta":"hi"}',
},
]);
});
it("createSseStream exposes toText", async () => {
const stream = Readable.from([
"data: {\"answer\":\"hello\"}\n\n",
"data: {\"delta\":\" world\"}\n\n",
'data: {"answer":"hello"}\n\n',
'data: {"delta":" world"}\n\n',
]);
const sseStream = createSseStream(stream, {
status: 200,
@@ -72,5 +90,6 @@ describe("sse parsing", () => {
});
expect(binary.status).toBe(200);
expect(binary.headers["content-type"]).toBe("audio/mpeg");
expect(binary.toReadable()).toBe(stream);
});
});

View File

@@ -1,12 +1,29 @@
import type { Readable } from "node:stream";
import { StringDecoder } from "node:string_decoder";
import type { BinaryStream, DifyStream, Headers, StreamEvent } from "../types/common";
import type {
BinaryStream,
DifyStream,
Headers,
JsonValue,
StreamEvent,
} from "../types/common";
import { isRecord } from "../internal/type-guards";
const toBufferChunk = (chunk: unknown): Buffer => {
if (Buffer.isBuffer(chunk)) {
return chunk;
}
if (chunk instanceof Uint8Array) {
return Buffer.from(chunk);
}
return Buffer.from(String(chunk));
};
const readLines = async function* (stream: Readable): AsyncIterable<string> {
const decoder = new StringDecoder("utf8");
let buffered = "";
for await (const chunk of stream) {
buffered += decoder.write(chunk as Buffer);
buffered += decoder.write(toBufferChunk(chunk));
let index = buffered.indexOf("\n");
while (index >= 0) {
let line = buffered.slice(0, index);
@@ -24,12 +41,12 @@ const readLines = async function* (stream: Readable): AsyncIterable<string> {
}
};
const parseMaybeJson = (value: string): unknown => {
const parseMaybeJson = (value: string): JsonValue | string | null => {
if (!value) {
return null;
}
try {
return JSON.parse(value);
return JSON.parse(value) as JsonValue;
} catch {
return value;
}
@@ -81,18 +98,17 @@ const extractTextFromEvent = (data: unknown): string => {
if (typeof data === "string") {
return data;
}
if (!data || typeof data !== "object") {
if (!isRecord(data)) {
return "";
}
const record = data as Record<string, unknown>;
if (typeof record.answer === "string") {
return record.answer;
if (typeof data.answer === "string") {
return data.answer;
}
if (typeof record.text === "string") {
return record.text;
if (typeof data.text === "string") {
return data.text;
}
if (typeof record.delta === "string") {
return record.delta;
if (typeof data.delta === "string") {
return data.delta;
}
return "";
};

View File

@@ -1,227 +0,0 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatClient, DifyClient, WorkflowClient, BASE_URL, routes } from "./index";
import axios from "axios";
const mockRequest = vi.fn();
const setupAxiosMock = () => {
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
};
beforeEach(() => {
vi.restoreAllMocks();
mockRequest.mockReset();
setupAxiosMock();
});
describe("Client", () => {
it("should create a client", () => {
new DifyClient("test");
expect(axios.create).toHaveBeenCalledWith({
baseURL: BASE_URL,
timeout: 60000,
});
});
it("should update the api key", () => {
const difyClient = new DifyClient("test");
difyClient.updateApiKey("test2");
expect(difyClient.getHttpClient().getSettings().apiKey).toBe("test2");
});
});
describe("Send Requests", () => {
it("should make a successful request to the application parameter", async () => {
const difyClient = new DifyClient("test");
const method = "GET";
const endpoint = routes.application.url();
mockRequest.mockResolvedValue({
status: 200,
data: "response",
headers: {},
});
await difyClient.sendRequest(method, endpoint);
const requestConfig = mockRequest.mock.calls[0][0];
expect(requestConfig).toMatchObject({
method,
url: endpoint,
params: undefined,
responseType: "json",
timeout: 60000,
});
expect(requestConfig.headers.Authorization).toBe("Bearer test");
});
it("uses the getMeta route configuration", async () => {
const difyClient = new DifyClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
await difyClient.getMeta("end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.getMeta.method,
url: routes.getMeta.url(),
params: { user: "end-user" },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
});
});
describe("File uploads", () => {
const OriginalFormData = globalThis.FormData;
beforeAll(() => {
globalThis.FormData = class FormDataMock {
append() {}
getHeaders() {
return {
"content-type": "multipart/form-data; boundary=test",
};
}
};
});
afterAll(() => {
globalThis.FormData = OriginalFormData;
});
it("does not override multipart boundary headers for FormData", async () => {
const difyClient = new DifyClient("test");
const form = new globalThis.FormData();
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
await difyClient.fileUpload(form, "end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.fileUpload.method,
url: routes.fileUpload.url(),
params: undefined,
headers: expect.objectContaining({
Authorization: "Bearer test",
"content-type": "multipart/form-data; boundary=test",
}),
responseType: "json",
timeout: 60000,
data: form,
}));
});
});
describe("Workflow client", () => {
it("uses tasks stop path for workflow stop", async () => {
const workflowClient = new WorkflowClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "stopped", headers: {} });
await workflowClient.stop("task-1", "end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.stopWorkflow.method,
url: routes.stopWorkflow.url("task-1"),
params: undefined,
headers: expect.objectContaining({
Authorization: "Bearer test",
"Content-Type": "application/json",
}),
responseType: "json",
timeout: 60000,
data: { user: "end-user" },
}));
});
it("maps workflow log filters to service api params", async () => {
const workflowClient = new WorkflowClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
await workflowClient.getLogs({
createdAtAfter: "2024-01-01T00:00:00Z",
createdAtBefore: "2024-01-02T00:00:00Z",
createdByEndUserSessionId: "sess-1",
createdByAccount: "acc-1",
page: 2,
limit: 10,
});
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: "GET",
url: "/workflows/logs",
params: {
created_at__after: "2024-01-01T00:00:00Z",
created_at__before: "2024-01-02T00:00:00Z",
created_by_end_user_session_id: "sess-1",
created_by_account: "acc-1",
page: 2,
limit: 10,
},
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
});
});
describe("Chat client", () => {
it("places user in query for suggested messages", async () => {
const chatClient = new ChatClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
await chatClient.getSuggested("msg-1", "end-user");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.getSuggested.method,
url: routes.getSuggested.url("msg-1"),
params: { user: "end-user" },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
});
it("uses last_id when listing conversations", async () => {
const chatClient = new ChatClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
await chatClient.getConversations("end-user", "last-1", 10);
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.getConversations.method,
url: routes.getConversations.url(),
params: { user: "end-user", last_id: "last-1", limit: 10 },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
});
it("lists app feedbacks without user params", async () => {
const chatClient = new ChatClient("test");
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
await chatClient.getAppFeedbacks(1, 20);
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: "GET",
url: "/app/feedbacks",
params: { page: 1, limit: 20 },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
});
});

View File

@@ -0,0 +1,240 @@
import { Readable } from "node:stream";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { BASE_URL, ChatClient, DifyClient, WorkflowClient, routes } from "./index";
const stubFetch = (): ReturnType<typeof vi.fn> => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
};
const jsonResponse = (body: unknown, init: ResponseInit = {}): Response =>
new Response(JSON.stringify(body), {
status: 200,
...init,
headers: {
"content-type": "application/json",
...(init.headers ?? {}),
},
});
describe("Client", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("creates a client with default settings", () => {
const difyClient = new DifyClient("test");
expect(difyClient.getHttpClient().getSettings()).toMatchObject({
apiKey: "test",
baseUrl: BASE_URL,
timeout: 60,
});
});
it("updates the api key", () => {
const difyClient = new DifyClient("test");
difyClient.updateApiKey("test2");
expect(difyClient.getHttpClient().getSettings().apiKey).toBe("test2");
});
});
describe("Send Requests", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("makes a successful request to the application parameter route", async () => {
const fetchMock = stubFetch();
const difyClient = new DifyClient("test");
const method = "GET";
const endpoint = routes.application.url();
fetchMock.mockResolvedValueOnce(jsonResponse("response"));
const response = await difyClient.sendRequest(method, endpoint);
expect(response).toMatchObject({
status: 200,
data: "response",
headers: {
"content-type": "application/json",
},
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${BASE_URL}${endpoint}`);
expect(init.method).toBe(method);
expect(init.headers).toMatchObject({
Authorization: "Bearer test",
"User-Agent": "dify-client-node",
});
});
it("uses the getMeta route configuration", async () => {
const fetchMock = stubFetch();
const difyClient = new DifyClient("test");
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }));
await difyClient.getMeta("end-user");
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${BASE_URL}${routes.getMeta.url()}?user=end-user`);
expect(init.method).toBe(routes.getMeta.method);
expect(init.headers).toMatchObject({
Authorization: "Bearer test",
});
});
});
describe("File uploads", () => {
const OriginalFormData = globalThis.FormData;
beforeAll(() => {
globalThis.FormData = class FormDataMock extends Readable {
constructor() {
super();
}
_read() {}
append() {}
getHeaders() {
return {
"content-type": "multipart/form-data; boundary=test",
};
}
} as unknown as typeof FormData;
});
afterAll(() => {
globalThis.FormData = OriginalFormData;
});
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("does not override multipart boundary headers for legacy FormData", async () => {
const fetchMock = stubFetch();
const difyClient = new DifyClient("test");
const form = new globalThis.FormData();
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }));
await difyClient.fileUpload(form, "end-user");
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${BASE_URL}${routes.fileUpload.url()}`);
expect(init.method).toBe(routes.fileUpload.method);
expect(init.headers).toMatchObject({
Authorization: "Bearer test",
"content-type": "multipart/form-data; boundary=test",
});
expect(init.body).not.toBe(form);
expect((init as RequestInit & { duplex?: string }).duplex).toBe("half");
});
});
describe("Workflow client", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("uses tasks stop path for workflow stop", async () => {
const fetchMock = stubFetch();
const workflowClient = new WorkflowClient("test");
fetchMock.mockResolvedValueOnce(jsonResponse({ result: "success" }));
await workflowClient.stop("task-1", "end-user");
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${BASE_URL}${routes.stopWorkflow.url("task-1")}`);
expect(init.method).toBe(routes.stopWorkflow.method);
expect(init.headers).toMatchObject({
Authorization: "Bearer test",
"Content-Type": "application/json",
});
expect(init.body).toBe(JSON.stringify({ user: "end-user" }));
});
it("maps workflow log filters to service api params", async () => {
const fetchMock = stubFetch();
const workflowClient = new WorkflowClient("test");
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }));
await workflowClient.getLogs({
createdAtAfter: "2024-01-01T00:00:00Z",
createdAtBefore: "2024-01-02T00:00:00Z",
createdByEndUserSessionId: "sess-1",
createdByAccount: "acc-1",
page: 2,
limit: 10,
});
const [url] = fetchMock.mock.calls[0] as [string, RequestInit];
const parsedUrl = new URL(url);
expect(parsedUrl.origin + parsedUrl.pathname).toBe(`${BASE_URL}/workflows/logs`);
expect(parsedUrl.searchParams.get("created_at__before")).toBe(
"2024-01-02T00:00:00Z"
);
expect(parsedUrl.searchParams.get("created_at__after")).toBe(
"2024-01-01T00:00:00Z"
);
expect(parsedUrl.searchParams.get("created_by_end_user_session_id")).toBe(
"sess-1"
);
expect(parsedUrl.searchParams.get("created_by_account")).toBe("acc-1");
expect(parsedUrl.searchParams.get("page")).toBe("2");
expect(parsedUrl.searchParams.get("limit")).toBe("10");
});
});
describe("Chat client", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("places user in query for suggested messages", async () => {
const fetchMock = stubFetch();
const chatClient = new ChatClient("test");
fetchMock.mockResolvedValueOnce(jsonResponse({ result: "success", data: [] }));
await chatClient.getSuggested("msg-1", "end-user");
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${BASE_URL}${routes.getSuggested.url("msg-1")}?user=end-user`);
expect(init.method).toBe(routes.getSuggested.method);
expect(init.headers).toMatchObject({
Authorization: "Bearer test",
});
});
it("uses last_id when listing conversations", async () => {
const fetchMock = stubFetch();
const chatClient = new ChatClient("test");
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }));
await chatClient.getConversations("end-user", "last-1", 10);
const [url] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${BASE_URL}${routes.getConversations.url()}?user=end-user&last_id=last-1&limit=10`);
});
it("lists app feedbacks without user params", async () => {
const fetchMock = stubFetch();
const chatClient = new ChatClient("test");
fetchMock.mockResolvedValueOnce(jsonResponse({ data: [] }));
await chatClient.getAppFeedbacks(1, 20);
const [url] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${BASE_URL}/app/feedbacks?page=1&limit=20`);
});
});

View File

@@ -0,0 +1,9 @@
export const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
export const hasStringProperty = <
TKey extends string,
>(
value: unknown,
key: TKey
): value is Record<TKey, string> => isRecord(value) && typeof value[key] === "string";

View File

@@ -15,4 +15,5 @@ export type AnnotationListOptions = {
keyword?: string;
};
export type AnnotationResponse = Record<string, unknown>;
export type AnnotationResponse = JsonObject;
import type { JsonObject } from "./common";

View File

@@ -1,17 +1,28 @@
import type { StreamEvent } from "./common";
import type {
DifyRequestFile,
JsonObject,
ResponseMode,
StreamEvent,
} from "./common";
export type ChatMessageRequest = {
inputs?: Record<string, unknown>;
inputs?: JsonObject;
query: string;
user: string;
response_mode?: "blocking" | "streaming";
files?: Array<Record<string, unknown>> | null;
response_mode?: ResponseMode;
files?: DifyRequestFile[] | null;
conversation_id?: string;
auto_generate_name?: boolean;
workflow_id?: string;
retriever_from?: "app" | "dataset";
};
export type ChatMessageResponse = Record<string, unknown>;
export type ChatMessageResponse = JsonObject;
export type ChatStreamEvent = StreamEvent<Record<string, unknown>>;
export type ChatStreamEvent = StreamEvent<JsonObject>;
export type ConversationSortBy =
| "created_at"
| "-created_at"
| "updated_at"
| "-updated_at";

View File

@@ -1,9 +1,18 @@
import type { Readable } from "node:stream";
export const DEFAULT_BASE_URL = "https://api.dify.ai/v1";
export const DEFAULT_TIMEOUT_SECONDS = 60;
export const DEFAULT_MAX_RETRIES = 3;
export const DEFAULT_RETRY_DELAY_SECONDS = 1;
export type RequestMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
export type ResponseMode = "blocking" | "streaming";
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
export type JsonObject = {
[key: string]: JsonValue;
};
export type JsonArray = JsonValue[];
export type QueryParamValue =
| string
@@ -15,6 +24,13 @@ export type QueryParamValue =
export type QueryParams = Record<string, QueryParamValue>;
export type Headers = Record<string, string>;
export type DifyRequestFile = JsonObject;
export type SuccessResponse = {
result: "success";
};
export type SuggestedQuestionsResponse = SuccessResponse & {
data: string[];
};
export type DifyClientConfig = {
apiKey: string;
@@ -54,18 +70,18 @@ export type StreamEvent<T = unknown> = {
};
export type DifyStream<T = unknown> = AsyncIterable<StreamEvent<T>> & {
data: NodeJS.ReadableStream;
data: Readable;
status: number;
headers: Headers;
requestId?: string;
toText(): Promise<string>;
toReadable(): NodeJS.ReadableStream;
toReadable(): Readable;
};
export type BinaryStream = {
data: NodeJS.ReadableStream;
data: Readable;
status: number;
headers: Headers;
requestId?: string;
toReadable(): NodeJS.ReadableStream;
toReadable(): Readable;
};

View File

@@ -1,13 +1,18 @@
import type { StreamEvent } from "./common";
import type {
DifyRequestFile,
JsonObject,
ResponseMode,
StreamEvent,
} from "./common";
export type CompletionRequest = {
inputs?: Record<string, unknown>;
response_mode?: "blocking" | "streaming";
inputs?: JsonObject;
response_mode?: ResponseMode;
user: string;
files?: Array<Record<string, unknown>> | null;
files?: DifyRequestFile[] | null;
retriever_from?: "app" | "dataset";
};
export type CompletionResponse = Record<string, unknown>;
export type CompletionResponse = JsonObject;
export type CompletionStreamEvent = StreamEvent<Record<string, unknown>>;
export type CompletionStreamEvent = StreamEvent<JsonObject>;

View File

@@ -14,7 +14,7 @@ export type DatasetCreateRequest = {
external_knowledge_api_id?: string | null;
provider?: string;
external_knowledge_id?: string | null;
retrieval_model?: Record<string, unknown> | null;
retrieval_model?: JsonObject | null;
embedding_model?: string | null;
embedding_model_provider?: string | null;
};
@@ -26,9 +26,9 @@ export type DatasetUpdateRequest = {
permission?: string | null;
embedding_model?: string | null;
embedding_model_provider?: string | null;
retrieval_model?: Record<string, unknown> | null;
retrieval_model?: JsonObject | null;
partial_member_list?: Array<Record<string, string>> | null;
external_retrieval_model?: Record<string, unknown> | null;
external_retrieval_model?: JsonObject | null;
external_knowledge_id?: string | null;
external_knowledge_api_id?: string | null;
};
@@ -61,12 +61,12 @@ export type DatasetTagUnbindingRequest = {
export type DocumentTextCreateRequest = {
name: string;
text: string;
process_rule?: Record<string, unknown> | null;
process_rule?: JsonObject | null;
original_document_id?: string | null;
doc_form?: string;
doc_language?: string;
indexing_technique?: string | null;
retrieval_model?: Record<string, unknown> | null;
retrieval_model?: JsonObject | null;
embedding_model?: string | null;
embedding_model_provider?: string | null;
};
@@ -74,10 +74,10 @@ export type DocumentTextCreateRequest = {
export type DocumentTextUpdateRequest = {
name?: string | null;
text?: string | null;
process_rule?: Record<string, unknown> | null;
process_rule?: JsonObject | null;
doc_form?: string;
doc_language?: string;
retrieval_model?: Record<string, unknown> | null;
retrieval_model?: JsonObject | null;
};
export type DocumentListOptions = {
@@ -92,7 +92,7 @@ export type DocumentGetOptions = {
};
export type SegmentCreateRequest = {
segments: Array<Record<string, unknown>>;
segments: JsonObject[];
};
export type SegmentUpdateRequest = {
@@ -155,8 +155,8 @@ export type MetadataOperationRequest = {
export type HitTestingRequest = {
query?: string | null;
retrieval_model?: Record<string, unknown> | null;
external_retrieval_model?: Record<string, unknown> | null;
retrieval_model?: JsonObject | null;
external_retrieval_model?: JsonObject | null;
attachment_ids?: string[] | null;
};
@@ -165,20 +165,21 @@ export type DatasourcePluginListOptions = {
};
export type DatasourceNodeRunRequest = {
inputs: Record<string, unknown>;
inputs: JsonObject;
datasource_type: string;
credential_id?: string | null;
is_published: boolean;
};
export type PipelineRunRequest = {
inputs: Record<string, unknown>;
inputs: JsonObject;
datasource_type: string;
datasource_info_list: Array<Record<string, unknown>>;
datasource_info_list: JsonObject[];
start_node_id: string;
is_published: boolean;
response_mode: "streaming" | "blocking";
response_mode: ResponseMode;
};
export type KnowledgeBaseResponse = Record<string, unknown>;
export type PipelineStreamEvent = Record<string, unknown>;
export type KnowledgeBaseResponse = JsonObject;
export type PipelineStreamEvent = JsonObject;
import type { JsonObject, ResponseMode } from "./common";

View File

@@ -1,12 +1,17 @@
import type { StreamEvent } from "./common";
import type {
DifyRequestFile,
JsonObject,
ResponseMode,
StreamEvent,
} from "./common";
export type WorkflowRunRequest = {
inputs?: Record<string, unknown>;
inputs?: JsonObject;
user: string;
response_mode?: "blocking" | "streaming";
files?: Array<Record<string, unknown>> | null;
response_mode?: ResponseMode;
files?: DifyRequestFile[] | null;
};
export type WorkflowRunResponse = Record<string, unknown>;
export type WorkflowRunResponse = JsonObject;
export type WorkflowStreamEvent = StreamEvent<Record<string, unknown>>;
export type WorkflowStreamEvent = StreamEvent<JsonObject>;

View File

@@ -1,2 +1,4 @@
import type { JsonObject } from "./common";
export type WorkspaceModelType = string;
export type WorkspaceModelsResponse = Record<string, unknown>;
export type WorkspaceModelsResponse = JsonObject;

View File

@@ -0,0 +1,137 @@
import { createServer } from "node:http";
import { Readable } from "node:stream";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { HttpClient } from "../src/http/client";
const readBody = async (stream: NodeJS.ReadableStream): Promise<Buffer> => {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
};
describe("HttpClient integration", () => {
const requests: Array<{
url: string;
method: string;
headers: Record<string, string | string[] | undefined>;
body: Buffer;
}> = [];
const server = createServer((req, res) => {
void (async () => {
const body = await readBody(req);
requests.push({
url: req.url ?? "",
method: req.method ?? "",
headers: req.headers,
body,
});
if (req.url?.startsWith("/json")) {
res.writeHead(200, { "content-type": "application/json", "x-request-id": "req-json" });
res.end(JSON.stringify({ ok: true }));
return;
}
if (req.url === "/stream") {
res.writeHead(200, { "content-type": "text/event-stream" });
res.end('data: {"answer":"hello"}\n\ndata: {"delta":" world"}\n\n');
return;
}
if (req.url === "/bytes") {
res.writeHead(200, { "content-type": "application/octet-stream" });
res.end(Buffer.from([1, 2, 3, 4]));
return;
}
if (req.url === "/upload-stream") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ received: body.toString("utf8") }));
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ message: "not found" }));
})();
});
let client: HttpClient;
beforeAll(async () => {
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address() as AddressInfo;
client = new HttpClient({
apiKey: "test-key",
baseUrl: `http://127.0.0.1:${address.port}`,
maxRetries: 0,
retryDelay: 0,
});
});
afterAll(async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
});
it("uses real fetch for query serialization and json bodies", async () => {
const response = await client.request({
method: "POST",
path: "/json",
query: { tag_ids: ["a", "b"], limit: 2 },
data: { user: "u" },
});
expect(response.requestId).toBe("req-json");
expect(response.data).toEqual({ ok: true });
expect(requests.at(-1)).toMatchObject({
url: "/json?tag_ids=a&tag_ids=b&limit=2",
method: "POST",
});
expect(requests.at(-1)?.headers.authorization).toBe("Bearer test-key");
expect(requests.at(-1)?.headers["content-type"]).toBe("application/json");
expect(requests.at(-1)?.body.toString("utf8")).toBe(JSON.stringify({ user: "u" }));
});
it("supports streaming request bodies with duplex fetch", async () => {
const response = await client.request<{ received: string }>({
method: "POST",
path: "/upload-stream",
data: Readable.from(["hello ", "world"]),
});
expect(response.data).toEqual({ received: "hello world" });
expect(requests.at(-1)?.body.toString("utf8")).toBe("hello world");
});
it("parses real sse responses into text", async () => {
const stream = await client.requestStream({
method: "GET",
path: "/stream",
});
await expect(stream.toText()).resolves.toBe("hello world");
});
it("parses real byte responses into buffers", async () => {
const response = await client.request<Buffer, "bytes">({
method: "GET",
path: "/bytes",
responseType: "bytes",
});
expect(Array.from(response.data.values())).toEqual([1, 2, 3, 4]);
});
});

View File

@@ -1,30 +0,0 @@
import axios from "axios";
import { vi } from "vitest";
import { HttpClient } from "../src/http/client";
export const createHttpClient = (configOverrides = {}) => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", ...configOverrides });
return { client, mockRequest };
};
export const createHttpClientWithSpies = (configOverrides = {}) => {
const { client, mockRequest } = createHttpClient(configOverrides);
const request = vi
.spyOn(client, "request")
.mockResolvedValue({ data: "ok", status: 200, headers: {} });
const requestStream = vi
.spyOn(client, "requestStream")
.mockResolvedValue({ data: null });
const requestBinaryStream = vi
.spyOn(client, "requestBinaryStream")
.mockResolvedValue({ data: null });
return {
client,
mockRequest,
request,
requestStream,
requestBinaryStream,
};
};

View File

@@ -0,0 +1,48 @@
import { vi } from "vitest";
import { HttpClient } from "../src/http/client";
import type { DifyClientConfig, DifyResponse } from "../src/types/common";
type FetchMock = ReturnType<typeof vi.fn>;
type RequestSpy = ReturnType<typeof vi.fn>;
type HttpClientWithFetchMock = {
client: HttpClient;
fetchMock: FetchMock;
};
type HttpClientWithSpies = HttpClientWithFetchMock & {
request: RequestSpy;
requestStream: RequestSpy;
requestBinaryStream: RequestSpy;
};
export const createHttpClient = (
configOverrides: Partial<DifyClientConfig> = {}
): HttpClientWithFetchMock => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const client = new HttpClient({ apiKey: "test", ...configOverrides });
return { client, fetchMock };
};
export const createHttpClientWithSpies = (
configOverrides: Partial<DifyClientConfig> = {}
): HttpClientWithSpies => {
const { client, fetchMock } = createHttpClient(configOverrides);
const request = vi
.spyOn(client, "request")
.mockResolvedValue({ data: "ok", status: 200, headers: {} } as DifyResponse<string>);
const requestStream = vi
.spyOn(client, "requestStream")
.mockResolvedValue({ data: null, status: 200, headers: {} } as never);
const requestBinaryStream = vi
.spyOn(client, "requestBinaryStream")
.mockResolvedValue({ data: null, status: 200, headers: {} } as never);
return {
client,
fetchMock,
request,
requestStream,
requestBinaryStream,
};
};

View File

@@ -3,7 +3,7 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"rootDir": "src",
"rootDir": ".",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
@@ -13,5 +13,5 @@
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

View File

@@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["**/*.test.js"],
include: ["**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "text-summary"],