options.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  1. import { ClientOptions } from '@elastic/elasticsearch';
  2. import { DeepRequired, EntityRelationPaths, ID, LanguageCode, Product, ProductVariant } from '@vendure/core';
  3. import deepmerge from 'deepmerge';
  4. import {
  5. CustomMapping,
  6. CustomScriptMapping,
  7. ElasticSearchInput,
  8. ElasticSearchSortInput,
  9. ElasticSearchSortParameter,
  10. GraphQlPrimitive,
  11. PrimitiveTypeVariations,
  12. } from './types';
  13. /**
  14. * @description
  15. * Configuration options for the {@link ElasticsearchPlugin}.
  16. *
  17. * @docsCategory ElasticsearchPlugin
  18. * @docsPage ElasticsearchOptions
  19. */
  20. export interface ElasticsearchOptions {
  21. /**
  22. * @description
  23. * The host of the Elasticsearch server. May also be specified in `clientOptions.node`.
  24. *
  25. * @default 'http://localhost'
  26. */
  27. host?: string;
  28. /**
  29. * @description
  30. * The port of the Elasticsearch server. May also be specified in `clientOptions.node`.
  31. *
  32. * @default 9200
  33. */
  34. port?: number;
  35. /**
  36. * @description
  37. * Maximum amount of attempts made to connect to the ElasticSearch server on startup.
  38. *
  39. * @default 10
  40. */
  41. connectionAttempts?: number;
  42. /**
  43. * @description
  44. * Interval in milliseconds between attempts to connect to the ElasticSearch server on startup.
  45. *
  46. * @default 5000
  47. */
  48. connectionAttemptInterval?: number;
  49. /**
  50. * @description
  51. * Options to pass directly to the
  52. * [Elasticsearch Node.js client](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html). For example, to
  53. * set authentication or other more advanced options.
  54. * Note that if the `node` or `nodes` option is specified, it will override the values provided in the `host` and `port` options.
  55. */
  56. clientOptions?: ClientOptions;
  57. /**
  58. * @description
  59. * Prefix for the indices created by the plugin.
  60. *
  61. * @default
  62. * 'vendure-'
  63. */
  64. indexPrefix?: string;
  65. /**
  66. * @description
  67. * [These options](https://www.elastic.co/guide/en/elasticsearch/reference/7.x/index-modules.html#index-modules-settings)
  68. * are directly passed to index settings. To apply some settings indices will be recreated.
  69. *
  70. * @example
  71. * ```TypeScript
  72. * // Configuring an English stemmer
  73. * indexSettings: {
  74. * analysis: {
  75. * analyzer: {
  76. * custom_analyzer: {
  77. * tokenizer: 'standard',
  78. * filter: [
  79. * 'lowercase',
  80. * 'english_stemmer'
  81. * ]
  82. * }
  83. * },
  84. * filter : {
  85. * english_stemmer : {
  86. * type : 'stemmer',
  87. * name : 'english'
  88. * }
  89. * }
  90. * }
  91. * },
  92. * ```
  93. * A more complete example can be found in the discussion thread
  94. * [How to make elastic plugin to search by substring with stemming](https://github.com/vendure-ecommerce/vendure/discussions/1066).
  95. *
  96. * @since 1.2.0
  97. * @default
  98. * {}
  99. */
  100. indexSettings?: object;
  101. /**
  102. * @description
  103. * This option allow to redefine or define new properties in mapping. More about elastic
  104. * [mapping](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html)
  105. * After changing this option indices will be recreated.
  106. *
  107. * @example
  108. * ```TypeScript
  109. * // Configuring custom analyzer for the `productName` field.
  110. * indexMappingProperties: {
  111. * productName: {
  112. * type: 'text',
  113. * analyzer:'custom_analyzer',
  114. * fields: {
  115. * keyword: {
  116. * type: 'keyword',
  117. * ignore_above: 256,
  118. * }
  119. * }
  120. * }
  121. * }
  122. * ```
  123. *
  124. * To reference a field defined by `customProductMappings` or `customProductVariantMappings`, you will
  125. * need to prefix the name with `'product-<name>'` or `'variant-<name>'` respectively, e.g.:
  126. *
  127. * @example
  128. * ```TypeScript
  129. * customProductMappings: {
  130. * variantCount: {
  131. * graphQlType: 'Int!',
  132. * valueFn: (product, variants) => variants.length,
  133. * },
  134. * },
  135. * indexMappingProperties: {
  136. * 'product-variantCount': {
  137. * type: 'integer',
  138. * }
  139. * }
  140. * ```
  141. *
  142. * @since 1.2.0
  143. * @default
  144. * {}
  145. */
  146. indexMappingProperties?: {
  147. [indexName: string]: object;
  148. };
  149. /**
  150. * @description
  151. * Batch size for bulk operations (e.g. when rebuilding the indices).
  152. *
  153. * @default
  154. * 2000
  155. */
  156. batchSize?: number;
  157. /**
  158. * @description
  159. * Configuration of the internal Elasticsearch query.
  160. */
  161. searchConfig?: SearchConfig;
  162. /**
  163. * @description
  164. * Custom mappings may be defined which will add the defined data to the
  165. * Elasticsearch index and expose that data via the SearchResult GraphQL type,
  166. * adding a new `customMappings` field.
  167. *
  168. * The `graphQlType` property may be one of `String`, `Int`, `Float`, `Boolean`, `ID` or list
  169. * versions thereof (`[String!]` etc) and can be appended with a `!` to indicate non-nullable fields.
  170. *
  171. * The `public` (default = `true`) property is used to reveal or hide the property in the GraphQL API schema.
  172. * If this property is set to `false` it's not accessible in the `customMappings` field but it's still getting
  173. * parsed to the elasticsearch index.
  174. *
  175. * This config option defines custom mappings which are accessible when the "groupByProduct"
  176. * input options is set to `true`.
  177. *
  178. * @example
  179. * ```TypeScript
  180. * customProductMappings: {
  181. * variantCount: {
  182. * graphQlType: 'Int!',
  183. * valueFn: (product, variants) => variants.length,
  184. * },
  185. * reviewRating: {
  186. * graphQlType: 'Float',
  187. * public: true,
  188. * valueFn: product => (product.customFields as any).reviewRating,
  189. * },
  190. * priority: {
  191. * graphQlType: 'Int!',
  192. * public: false,
  193. * valueFn: product => (product.customFields as any).priority,
  194. * },
  195. * }
  196. * ```
  197. *
  198. * @example
  199. * ```SDL
  200. * query SearchProducts($input: SearchInput!) {
  201. * search(input: $input) {
  202. * totalItems
  203. * items {
  204. * productId
  205. * productName
  206. * customMappings {
  207. * ...on CustomProductMappings {
  208. * variantCount
  209. * reviewRating
  210. * }
  211. * }
  212. * }
  213. * }
  214. * }
  215. * ```
  216. */
  217. customProductMappings?: {
  218. [fieldName: string]: CustomMapping<[Product, ProductVariant[], LanguageCode]>;
  219. };
  220. /**
  221. * @description
  222. * This config option defines custom mappings which are accessible when the "groupByProduct"
  223. * input options is set to `false`.
  224. *
  225. * @example
  226. * ```SDL
  227. * query SearchProducts($input: SearchInput!) {
  228. * search(input: $input) {
  229. * totalItems
  230. * items {
  231. * productId
  232. * productName
  233. * customMappings {
  234. * ...on CustomProductVariantMappings {
  235. * weight
  236. * }
  237. * }
  238. * }
  239. * }
  240. * }
  241. * ```
  242. */
  243. customProductVariantMappings?: {
  244. [fieldName: string]: CustomMapping<[ProductVariant, LanguageCode]>;
  245. };
  246. /**
  247. * @description
  248. * If set to `true`, updates to Products, ProductVariants and Collections will not immediately
  249. * trigger an update to the search index. Instead, all these changes will be buffered and will
  250. * only be run via a call to the `runPendingSearchIndexUpdates` mutation in the Admin API.
  251. *
  252. * This is very useful for installations with a large number of ProductVariants and/or
  253. * Collections, as the buffering allows better control over when these expensive jobs are run,
  254. * and also performs optimizations to minimize the amount of work that needs to be performed by
  255. * the worker.
  256. *
  257. * @since 1.3.0
  258. * @default false
  259. */
  260. bufferUpdates?: boolean;
  261. /**
  262. * @description
  263. * Additional product relations that will be fetched from DB while reindexing. This can be used
  264. * in combination with `customProductMappings` to ensure that the required relations are joined
  265. * before the `product` object is passed to the `valueFn`.
  266. *
  267. * @example
  268. * ```TypeScript
  269. * {
  270. * hydrateProductRelations: ['assets.asset'],
  271. * customProductMappings: {
  272. * assetPreviews: {
  273. * graphQlType: '[String!]',
  274. * // Here we can be sure that the `product.assets` array is populated
  275. * // with an Asset object
  276. * valueFn: (product) => product.assets.map(asset => asset.preview),
  277. * }
  278. * }
  279. * }
  280. * ```
  281. *
  282. * @default []
  283. * @since 1.3.0
  284. */
  285. hydrateProductRelations?: Array<EntityRelationPaths<Product>>;
  286. /**
  287. * @description
  288. * Additional variant relations that will be fetched from DB while reindexing. See
  289. * `hydrateProductRelations` for more explanation and a usage example.
  290. *
  291. * @default []
  292. * @since 1.3.0
  293. */
  294. hydrateProductVariantRelations?: Array<EntityRelationPaths<ProductVariant>>;
  295. /**
  296. * @description
  297. * Allows the `SearchInput` type to be extended with new input fields. This allows arbitrary
  298. * data to be passed in, which can then be used e.g. in the `mapQuery()` function or
  299. * custom `scriptFields` functions.
  300. *
  301. * @example
  302. * ```TypeScript
  303. * extendSearchInputType: {
  304. * longitude: 'Float',
  305. * latitude: 'Float',
  306. * radius: 'Float',
  307. * }
  308. * ```
  309. *
  310. * This allows the search query to include these new fields:
  311. *
  312. * @example
  313. * ```GraphQl
  314. * query {
  315. * search(input: {
  316. * longitude: 101.7117,
  317. * latitude: 3.1584,
  318. * radius: 50.00
  319. * }) {
  320. * items {
  321. * productName
  322. * }
  323. * }
  324. * }
  325. * ```
  326. *
  327. * @default {}
  328. * @since 1.3.0
  329. */
  330. extendSearchInputType?: {
  331. [name: string]: PrimitiveTypeVariations<GraphQlPrimitive>;
  332. };
  333. /**
  334. * @description
  335. * Adds a list of sort parameters. This is mostly important to make the
  336. * correct sort order values available inside `input` parameter of the `mapSort` option.
  337. *
  338. * @example
  339. * ```TypeScript
  340. * extendSearchSortType: ["distance"]
  341. * ```
  342. *
  343. * will extend the `SearchResultSortParameter` input type like this:
  344. *
  345. * @example
  346. * ```GraphQl
  347. * extend input SearchResultSortParameter {
  348. * distance: SortOrder
  349. * }
  350. * ```
  351. *
  352. * @default []
  353. * @since 1.4.0
  354. */
  355. extendSearchSortType?: string[];
  356. }
  357. /**
  358. * @description
  359. * Configuration options for the internal Elasticsearch query which is generated when performing a search.
  360. *
  361. * @docsCategory ElasticsearchPlugin
  362. * @docsPage ElasticsearchOptions
  363. */
  364. export interface SearchConfig {
  365. /**
  366. * @description
  367. * The maximum number of FacetValues to return from the search query. Internally, this
  368. * value sets the "size" property of an Elasticsearch aggregation.
  369. *
  370. * @default
  371. * 50
  372. */
  373. facetValueMaxSize?: number;
  374. /**
  375. * @description
  376. * The maximum number of Collections to return from the search query. Internally, this
  377. * value sets the "size" property of an Elasticsearch aggregation.
  378. *
  379. * @since 1.1.0
  380. * @default
  381. * 50
  382. */
  383. collectionMaxSize?: number;
  384. /**
  385. * @description
  386. * The maximum number of totalItems to return from the search query. Internally, this
  387. * value sets the "track_total_hits" property of an Elasticsearch query.
  388. * If this parameter is set to "True", accurate count of totalItems will be returned.
  389. * If this parameter is set to "False", totalItems will be returned as 0.
  390. * If this parameter is set to integer, accurate count of totalItems will be returned not bigger than integer.
  391. *
  392. * @since 1.2.0
  393. * @default
  394. * 10000
  395. */
  396. totalItemsMaxSize?: number | boolean;
  397. // prettier-ignore
  398. /**
  399. * @description
  400. * Defines the
  401. * [multi match type](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html#multi-match-types)
  402. * used when matching against a search term.
  403. *
  404. * @default
  405. * 'best_fields'
  406. */
  407. multiMatchType?: 'best_fields' | 'most_fields' | 'cross_fields' | 'phrase' | 'phrase_prefix' | 'bool_prefix';
  408. /**
  409. * @description
  410. * Set custom boost values for particular fields when matching against a search term.
  411. */
  412. boostFields?: BoostFieldsConfig;
  413. /**
  414. * @description
  415. * The interval used to group search results into buckets according to price range. For example, setting this to
  416. * `2000` will group into buckets every $20.00:
  417. *
  418. * ```JSON
  419. * {
  420. * "data": {
  421. * "search": {
  422. * "totalItems": 32,
  423. * "priceRange": {
  424. * "buckets": [
  425. * {
  426. * "to": 2000,
  427. * "count": 21
  428. * },
  429. * {
  430. * "to": 4000,
  431. * "count": 7
  432. * },
  433. * {
  434. * "to": 6000,
  435. * "count": 3
  436. * },
  437. * {
  438. * "to": 12000,
  439. * "count": 1
  440. * }
  441. * ]
  442. * }
  443. * }
  444. * }
  445. * }
  446. * ```
  447. */
  448. priceRangeBucketInterval?: number;
  449. /**
  450. * @description
  451. * This config option allows the the modification of the whole (already built) search query. This allows
  452. * for e.g. wildcard / fuzzy searches on the index.
  453. *
  454. * @example
  455. * ```TypeScript
  456. * mapQuery: (query, input, searchConfig, channelId, enabledOnly){
  457. * if(query.bool.must){
  458. * delete query.bool.must;
  459. * }
  460. * query.bool.should = [
  461. * {
  462. * query_string: {
  463. * query: "*" + term + "*",
  464. * fields: [
  465. * `productName^${searchConfig.boostFields.productName}`,
  466. * `productVariantName^${searchConfig.boostFields.productVariantName}`,
  467. * ]
  468. * }
  469. * },
  470. * {
  471. * multi_match: {
  472. * query: term,
  473. * type: searchConfig.multiMatchType,
  474. * fields: [
  475. * `description^${searchConfig.boostFields.description}`,
  476. * `sku^${searchConfig.boostFields.sku}`,
  477. * ],
  478. * },
  479. * },
  480. * ];
  481. *
  482. * return query;
  483. * }
  484. * ```
  485. */
  486. mapQuery?: (
  487. query: any,
  488. input: ElasticSearchInput,
  489. searchConfig: DeepRequired<SearchConfig>,
  490. channelId: ID,
  491. enabledOnly: boolean,
  492. ) => any;
  493. /**
  494. * @description
  495. * Sets `script_fields` inside the elasticsearch body which allows returning a script evaluation for each hit.
  496. *
  497. * The script field definition consists of three properties:
  498. *
  499. * * `graphQlType`: This is the type that will be returned when this script field is queried
  500. * via the GraphQL API. It may be one of `String`, `Int`, `Float`, `Boolean`, `ID` or list
  501. * versions thereof (`[String!]` etc) and can be appended with a `!` to indicate non-nullable fields.
  502. * * `context`: determines whether this script field is available when grouping by product. Can be
  503. * `product`, `variant` or `both`.
  504. * * `scriptFn`: This is the function to run on each hit. Should return an object with a `script` property,
  505. * as covered in the
  506. * [Elasticsearch script fields docs](https://www.elastic.co/guide/en/elasticsearch/reference/7.15/search-fields.html#script-fields)
  507. *
  508. * @example
  509. * ```TypeScript
  510. * extendSearchInputType: {
  511. * latitude: 'Float',
  512. * longitude: 'Float',
  513. * },
  514. * indexMappingProperties: {
  515. * // The `product-location` field corresponds to the `location` customProductMapping
  516. * // defined below. Here we specify that it would be index as a `geo_point` type,
  517. * // which will allow us to perform geo-spacial calculations on it in our script field.
  518. * 'product-location': {
  519. * type: 'geo_point', // contains function arcDistance
  520. * },
  521. * },
  522. * customProductMappings: {
  523. * location: {
  524. * graphQlType: 'String',
  525. * valueFn: (product: Product) => {
  526. * // Assume that the Product entity has this customField defined
  527. * const custom = product.customFields.location;
  528. * return `${custom.latitude},${custom.longitude}`;
  529. * },
  530. * }
  531. * },
  532. * searchConfig: {
  533. * scriptFields: {
  534. * distance: {
  535. * graphQlType: 'Float!',
  536. * // Run this script only when grouping results by product
  537. * context: 'product',
  538. * scriptFn: (input) => {
  539. * // The SearchInput was extended with latitude and longitude
  540. * // via the `extendSearchInputType` option above.
  541. * const lat = input.latitude;
  542. * const lon = input.longitude;
  543. * return {
  544. * script: `doc['product-location'].arcDistance(${lat}, ${lon})`,
  545. * }
  546. * }
  547. * }
  548. * }
  549. * }
  550. * ```
  551. *
  552. * @since 1.3.0
  553. */
  554. scriptFields?: { [fieldName: string]: CustomScriptMapping<[ElasticSearchInput]> };
  555. /**
  556. * @description
  557. * Allows extending the `sort` input of the elasticsearch body as covered in
  558. * [Elasticsearch sort docs](https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html)
  559. *
  560. * @example
  561. * ```TS
  562. * mapSort: (sort, input) => {
  563. * // Assuming `extendSearchSortType: ["priority"]`
  564. * // Assuming priority is never undefined
  565. * const { priority } = input.sort;
  566. * return [
  567. * ...sort,
  568. * {
  569. * // The `product-priority` field corresponds to the `priority` customProductMapping
  570. * // Depending on the index type, this field might require a
  571. * // more detailed input (example: 'productName.keyword')
  572. * ["product-priority"]: {
  573. * order: priority === SortOrder.ASC ? 'asc' : 'desc'
  574. * }
  575. * }
  576. * ];
  577. * }
  578. * ```
  579. *
  580. * A more generic example would be a sort function based on a product location like this:
  581. * @example
  582. * ```TS
  583. * extendSearchInputType: {
  584. * latitude: 'Float',
  585. * longitude: 'Float',
  586. * },
  587. * extendSearchSortType: ["distance"],
  588. * indexMappingProperties: {
  589. * // The `product-location` field corresponds to the `location` customProductMapping
  590. * // defined below. Here we specify that it would be index as a `geo_point` type,
  591. * // which will allow us to perform geo-spacial calculations on it in our script field.
  592. * 'product-location': {
  593. * type: 'geo_point',
  594. * },
  595. * },
  596. * customProductMappings: {
  597. * location: {
  598. * graphQlType: 'String',
  599. * valueFn: (product: Product) => {
  600. * // Assume that the Product entity has this customField defined
  601. * const custom = product.customFields.location;
  602. * return `${custom.latitude},${custom.longitude}`;
  603. * },
  604. * }
  605. * },
  606. * searchConfig: {
  607. * mapSort: (sort, input) => {
  608. * // Assuming distance is never undefined
  609. * const { distance } = input.sort;
  610. * return [
  611. * ...sort,
  612. * {
  613. * ["_geo_distance"]: {
  614. * "product-location": [
  615. * input.longitude,
  616. * input.latitude
  617. * ],
  618. * order: distance === SortOrder.ASC ? 'asc' : 'desc',
  619. * unit: "km"
  620. * }
  621. * }
  622. * ];
  623. * }
  624. * }
  625. * ```
  626. *
  627. * @default {}
  628. * @since 1.4.0
  629. */
  630. mapSort?: (sort: ElasticSearchSortInput, input: ElasticSearchInput) => ElasticSearchSortInput;
  631. }
  632. /**
  633. * @description
  634. * Configuration for [boosting](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html#field-boost)
  635. * the scores of given fields when performing a search against a term.
  636. *
  637. * Boosting a field acts as a score multiplier for matches against that field.
  638. *
  639. * @docsCategory ElasticsearchPlugin
  640. * @docsPage ElasticsearchOptions
  641. */
  642. export interface BoostFieldsConfig {
  643. /**
  644. * @description
  645. * Defines the boost factor for the productName field.
  646. *
  647. * @default 1
  648. */
  649. productName?: number;
  650. /**
  651. * @description
  652. * Defines the boost factor for the productVariantName field.
  653. *
  654. * @default 1
  655. */
  656. productVariantName?: number;
  657. /**
  658. * @description
  659. * Defines the boost factor for the description field.
  660. *
  661. * @default 1
  662. */
  663. description?: number;
  664. /**
  665. * @description
  666. * Defines the boost factor for the sku field.
  667. *
  668. * @default 1
  669. */
  670. sku?: number;
  671. }
  672. export type ElasticsearchRuntimeOptions = DeepRequired<Omit<ElasticsearchOptions, 'clientOptions'>> & {
  673. clientOptions?: ClientOptions;
  674. };
  675. export const defaultOptions: ElasticsearchRuntimeOptions = {
  676. host: 'http://localhost',
  677. port: 9200,
  678. connectionAttempts: 10,
  679. connectionAttemptInterval: 5000,
  680. indexPrefix: 'vendure-',
  681. indexSettings: {},
  682. indexMappingProperties: {},
  683. batchSize: 2000,
  684. searchConfig: {
  685. facetValueMaxSize: 50,
  686. collectionMaxSize: 50,
  687. totalItemsMaxSize: 10000,
  688. multiMatchType: 'best_fields',
  689. boostFields: {
  690. productName: 1,
  691. productVariantName: 1,
  692. description: 1,
  693. sku: 1,
  694. },
  695. priceRangeBucketInterval: 1000,
  696. mapQuery: query => query,
  697. mapSort: sort => sort,
  698. scriptFields: {},
  699. },
  700. customProductMappings: {},
  701. customProductVariantMappings: {},
  702. bufferUpdates: false,
  703. hydrateProductRelations: [],
  704. hydrateProductVariantRelations: [],
  705. extendSearchInputType: {},
  706. extendSearchSortType: [],
  707. };
  708. export function mergeWithDefaults(userOptions: ElasticsearchOptions): ElasticsearchRuntimeOptions {
  709. const { clientOptions, ...pluginOptions } = userOptions;
  710. const merged = deepmerge(defaultOptions, pluginOptions) as ElasticsearchRuntimeOptions;
  711. return { ...merged, clientOptions };
  712. }