ChemPal Documentation - v1.7.0
    Preparing search index...

    Factory class for querying multiple chemical suppliers simultaneously. This class provides a unified interface to search across multiple supplier implementations.

    // Create a factory to search all suppliers
    const factory = new SupplierFactory("sodium chloride", { controller: new AbortController() });

    // Create a factory to search specific suppliers
    const factory = new SupplierFactory("sodium chloride", {
    controller: new AbortController(),
    suppliers: ["SupplierCarolina", "SupplierLaballey"],
    });

    // Iterate over results from all selected suppliers
    for await (const product of factory) {
    console.log(product.supplier, product.title, product.price);
    }
    export class SupplierFactory<P extends Product> {
    // Term being queried
    private query: string;

    // Abort controller for fetch control
    private controller: AbortController;

    // List of supplier class names to include in query results. Held as plain strings
    // because it's compared against the runtime string keys of the suppliers barrel; the
    // public option is typed SupplierClassName[] for authoring convenience.
    private suppliers: Array<string>;

    // Mirrors userSettings.disabledSuppliers. Supplier class names the user has turned
    // off; excluded from every search before the include-list is even consulted, so the
    // deny-list wins even when `suppliers` is empty (which otherwise means "query all").
    // Held as plain strings for the same reason as `suppliers` above.
    private disabledSuppliers: Array<string>;

    // Maximum number of results for each supplier
    private limit: number = search.defaultResultsLimitPerSupplier;

    // Whether supplier caches (query + product detail) should be read from and
    // written to for this search. Mirrors userSettings.caching so a user who has
    // disabled caching gets fresh results every time.
    private caching: boolean;

    // Mirrors userSettings.doNotCacheEmptyResults. Forwarded to each supplier's
    // SupplierCache so an empty-result query for a previously out-of-stock
    // supplier doesn't get cached and mask future restocks.
    private doNotCacheEmptyResults: boolean;

    // Mirrors userSettings.cacheTtlMinutes. Forwarded to each supplier's
    // SupplierCache so query cache entries older than this are evicted on read.
    // 0 disables TTL expiration.
    private cacheTtlMinutes: number;

    // Mirrors userSettings.noCacheStatusCodes. Forwarded to each supplier so a
    // product-detail fetch that hit one of these HTTP statuses (default [429])
    // isn't cached, letting a later search retry it.
    private noCacheStatusCodes: number[];

    // Optional global fuzz-scorer override from userSettings.fuzzScorerOverride.
    // When set, applied to every supplier instance before execute() runs so the
    // user's Advanced-settings choice wins over each supplier class's default.
    private fuzzScorerOverride?: string;

    // Optional global max-search-time override (seconds) from userSettings.supplierSearchTimeBudgetSec.
    // When set, applied to every supplier instance so the user's Advanced-settings value
    // overrides each supplier class's default search-time budget.
    private supplierSearchTimeBudgetSec?: number;

    // Parsed advanced-search query, derived once from `query` and shared with every
    // supplier instance so they all see the same AST.
    private parsedQuery: ParsedSearchQuery;

    // Mirrors userSettings.fuzzyFilteringDisabled. When true, suppliers skip
    // fuzzball scoring and rely on the boolean predicate (or raw results) instead.
    private fuzzyFilteringDisabled: boolean;

    // User's location (ISO 3166-1 alpha-2 country code) from userSettings.location.
    // Used together with excludeNonShippingSuppliers to drop suppliers that don't
    // ship to the user. Empty/undefined disables shipping filtering.
    private location?: string;

    // Mirrors userSettings.excludeNonShippingSuppliers. When true (and a location
    // is set), suppliers that don't ship to the user's location are filtered out
    // before their queries run. Defaults to false so existing callers are unaffected.
    private excludeNonShippingSuppliers: boolean;

    // Mirrors userSettings.hideRestrictedProducts. When true (the default), each
    // yielded product is run through filterRestrictedProduct: options the user can't
    // buy (region/buyer/etc.) are pruned, and fully-restricted products are dropped.
    private hideRestrictedProducts: boolean;

    // SMILES/structure query terms resolved to chemical identifiers, keyed by the
    // raw term. Resolved lazily once per search (see resolveStructuresOnce) and
    // shared with every supplier instance so none of them re-hit the network.
    private resolvedStructures?: ReadonlyMap<string, ResolvedStructure>;

    // Logger instance
    private logger: Logger;

    // Set true by executeAll/executeAllStream when there were candidate suppliers
    // but shipping filtering removed every one of them (i.e. no selected supplier
    // ships to the user's location). Lets the UI explain an empty result set.
    public shippingExcludedAll: boolean = false;

    // Per-supplier exceptions from the most recent run (aborts excluded). Also
    // aggregated into an AggregateError recorded to the shared error buffer.
    public executionErrors: SupplierExecutionError<P>[] = [];

    /**
    * Factory class for querying all suppliers.
    *
    * @param query - Value to query for
    * @param options - Factory configuration; see {@link SupplierFactoryOptions}. `controller` is
    * required, everything else is optional with sensible defaults.
    * @source
    */
    constructor(query: string, options: SupplierFactoryOptions) {
    const {
    controller,
    limit = search.defaultResultsLimitPerSupplier,
    suppliers = [],
    caching = true,
    fuzzScorerOverride,
    doNotCacheEmptyResults = false,
    cacheTtlMinutes = 0,
    noCacheStatusCodes = [429],
    supplierSearchTimeBudgetSec,
    fuzzyFilteringDisabled = false,
    location,
    excludeNonShippingSuppliers = false,
    hideRestrictedProducts = false,
    disabledSuppliers = [],
    } = options;

    this.logger = new Logger('SupplierFactory');
    this.logger.debug('initialized', {
    query,
    limit,
    controller,
    suppliers,
    caching,
    fuzzScorerOverride,
    doNotCacheEmptyResults,
    cacheTtlMinutes,
    noCacheStatusCodes,
    supplierSearchTimeBudgetSec,
    fuzzyFilteringDisabled,
    location,
    excludeNonShippingSuppliers,
    hideRestrictedProducts,
    disabledSuppliers,
    });

    this.query = query;
    this.limit = limit;
    this.controller = controller;
    this.suppliers = suppliers;
    this.caching = caching;
    this.fuzzScorerOverride = fuzzScorerOverride;
    this.doNotCacheEmptyResults = doNotCacheEmptyResults;
    this.cacheTtlMinutes = cacheTtlMinutes;
    this.noCacheStatusCodes = noCacheStatusCodes;
    this.supplierSearchTimeBudgetSec = supplierSearchTimeBudgetSec;
    this.fuzzyFilteringDisabled = fuzzyFilteringDisabled;
    this.location = location;
    this.excludeNonShippingSuppliers = excludeNonShippingSuppliers;
    this.hideRestrictedProducts = hideRestrictedProducts;
    this.disabledSuppliers = disabledSuppliers;
    this.parsedQuery = parseSearchQuery(query);
    }

    /**
    * Get the list of available supplier module names.
    * Use these names when specifying which suppliers to query in the constructor.
    *
    * @returns Array of supplier class names that can be queried
    * @example
    * ```typescript
    * const suppliers = SupplierFactory.supplierList();
    * // Returns: ["SupplierCarolina", "SupplierLaballey", "SupplierBioFuranChem", ...]
    *
    * // Use these names to create a targeted factory
    * const factory = new SupplierFactory("acid", { controller, suppliers });
    * ```
    * @source
    */
    public static supplierList(): Array<SupplierClassName> {
    return Object.keys(suppliers) as unknown as Array<SupplierClassName>;
    }

    /**
    * Type guard narrowing an arbitrary string to a known {@link SupplierClassName}. Use at
    * runtime boundaries (persisted values, UI inputs) where a plain string needs to be
    * confirmed as one of the barrel-exported supplier names before it's stored or searched.
    * @param value - Candidate string to test.
    * @returns True (and narrows `value` to `SupplierClassName`) when it names an exported supplier.
    * @example
    * ```typescript
    * ["SupplierCarolina", "Nope"].filter(SupplierFactory.isSupplierClassName);
    * // => ["SupplierCarolina"]
    * ```
    * @source
    */
    public static isSupplierClassName(value: string): value is SupplierClassName {
    return SupplierFactory.supplierList().some((name) => name === value);
    }

    /**
    * Get a map of supplier module names to their display names.
    *
    * @returns Record mapping supplier class names to their supplierName property
    * @source
    */
    public static supplierDisplayNames(): Record<string, string> {
    return Object.fromEntries(
    // eslint-disable-next-line @typescript-eslint/naming-convention
    Object.entries(suppliers).map(([key, SupplierClass]) => [
    // Read the class's static supplierName directly — no instance needed.
    key,
    (SupplierClass as unknown as { supplierName: string }).supplierName,
    ]),
    );
    }

    /**
    * Get a map of supplier class names to whether they ship to the given location.
    * Creates throwaway instances and delegates to
    * {@link SupplierBase.shipsToCountry}, so it applies the same `shipsTo`/scope
    * heuristic used at search time. Lets the UI grey out suppliers that won't ship
    * to the user.
    *
    * @param location - The user's location as an ISO 3166-1 alpha-2 country code.
    * @returns Record mapping supplier class names to a ships-to boolean.
    * @example
    * ```typescript
    * const map = SupplierFactory.supplierShipsTo("US");
    * // { SupplierCarolina: true, SupplierWarchem: false, ... }
    * ```
    * @source
    */
    public static supplierShipsTo(location: CountryCode): Record<string, boolean> {
    return Object.fromEntries(
    // eslint-disable-next-line @typescript-eslint/naming-convention
    Object.entries(suppliers).map(([key, SupplierClass]) => {
    // Read the class's static shipping metadata directly — no instance needed.
    const meta = SupplierClass as unknown as SupplierStaticMeta;
    return [key, SupplierBase.shipsToCountryStatic(meta, location)];
    }),
    );
    }

    /**
    * Get a map of supplier class names to their home country and shipping scope,
    * the same fields stamped onto products. Lets the UI (and the search) reason
    * about which suppliers are compatible with the drawer's shipping/country
    * filters without querying them. Reads each supplier's `static` metadata, so no
    * instances are created.
    * @returns Record mapping supplier class names to `{ country, shipping }`.
    * @example
    * ```typescript
    * const meta = SupplierFactory.supplierShippingMeta();
    * // { SupplierCarolina: { country: "US", shipping: "domestic" }, ... }
    * ```
    * @source
    */
    public static supplierShippingMeta(): Record<
    string,
    { country: CountryCode; shipping: ShippingRange }
    > {
    return Object.fromEntries(
    // eslint-disable-next-line @typescript-eslint/naming-convention
    Object.entries(suppliers).map(([key, SupplierClass]) => {
    const meta = SupplierClass as unknown as SupplierStaticMeta;
    return [key, { country: meta.country, shipping: meta.shipping }];
    }),
    );
    }

    /**
    * Get a map of supplier class names to their required host origins, read from
    * each supplier's static `requiredHosts` (derived from its static baseURL/apiURL)
    * — no instances are created.
    * @returns Record mapping supplier class names to their requiredHosts arrays
    * @source
    */
    public static supplierRequiredHosts(): Record<string, string[]> {
    return Object.fromEntries(
    // eslint-disable-next-line @typescript-eslint/naming-convention
    Object.entries(suppliers).map(([key, SupplierClass]) => [
    key,
    (SupplierClass as unknown as { requiredHosts: string[] }).requiredHosts,
    ]),
    );
    }

    /**
    * Filters supplier instances to only those whose required host permissions
    * are already granted. Uses chrome.permissions.contains() which is a passive
    * check — it does not prompt the user. Permission granting should be handled
    * separately in a UI flow (e.g., a settings page).
    *
    * @param instances - Array of supplier instances to check
    * @returns Filtered array of suppliers with granted permissions
    * @source
    */
    private async filterByPermissions<P extends Product>(
    instances: SupplierBase<unknown, P>[],
    ): Promise<SupplierBase<unknown, P>[]> {
    const results = await Promise.all(
    instances.map(async (instance) => {
    if (instance.requiredHosts.length === 0) return { instance, granted: true };
    try {
    const granted = await chrome.permissions.contains({ origins: instance.requiredHosts });
    if (!granted) {
    this.logger.warn('Permission check failed for supplier', {
    supplier: instance.supplierName,
    requiredHosts: instance.requiredHosts,
    });
    }
    return { instance, granted };
    } catch (e) {
    this.logger.error('Permission check failed for supplier', {
    supplier: instance.supplierName,
    error: e,
    });
    return { instance, granted: false };
    }
    }),
    );
    return results.filter((r) => r.granted).map((r) => r.instance);
    }

    /**
    * Filters supplier instances to only those that ship to the user's location.
    * A no-op (returns all instances) when shipping filtering is disabled or no
    * location is set. Synchronous — unlike {@link filterByPermissions}, it only
    * reads in-memory supplier metadata via {@link SupplierBase.shipsToCountry}.
    *
    * @param instances - Array of supplier instances to check.
    * @returns The subset of instances that ship to the user's location.
    * @source
    */
    private filterByShipping<P extends Product>(
    instances: SupplierBase<unknown, P>[],
    ): SupplierBase<unknown, P>[] {
    if (!this.excludeNonShippingSuppliers || !this.location) {
    return instances;
    }
    const location = this.location as CountryCode;
    return instances.filter((instance) => instance.shipsToCountry(location));
    }

    /**
    * Applies per-product purchase-restriction filtering to a single result. A no-op
    * (returns the product unchanged) when `hideRestrictedProducts` is off; otherwise
    * delegates to {@link filterRestrictedProduct}, which prunes options the user can't
    * buy and returns undefined when the whole product is unbuyable.
    *
    * @param product - The product to filter.
    * @returns The product (possibly with restricted variants pruned), or undefined to drop it.
    * @source
    */
    private applyRestrictionFilter<Q extends Product>(product: Q): Q | undefined {
    if (this.hideRestrictedProducts !== true) {
    return product;
    }
    return filterRestrictedProduct(product, this.location);
    }

    /**
    * Resolves every chemical-identifier term in the query — SMILES/structure, CAS
    * number, or molecular formula — to its chemical identifiers (name, CAS,
    * InChIKey) exactly once, memoizing the result on the factory so it is shared
    * with every supplier instead of each supplier re-hitting the network.
    *
    * Only positive (non-negated) identifier leaf terms are resolved: a structure
    * (via {@link looksLikeSmiles} or an explicit `smiles:`/`inchikey:` prefix) via
    * {@link resolveSmiles}, or a CAS/formula (via {@link detectTermType}) via
    * {@link resolveIdentifierNames}. Plain name queries resolve nothing and make no
    * network calls. Each unique term is resolved once; failures are logged and
    * skipped so a dead resolver never blocks the search. Every supplier then swaps
    * an identifier it can't search for the resolved name (see
    * `SupplierBase.effectiveQuery`).
    *
    * @returns A map of raw search term → resolved structure (empty when none apply).
    * @example
    * ```typescript
    * // query "CCO" -> Map { "CCO" => { name: "ethanol", cas: ["64-17-5"], ... } }
    * // query "Na6O18P6" -> Map { "Na6O18P6" => { name: "Hexasodium hexametaphosphate", ... } }
    * await factory.resolveStructuresOnce();
    * ```
    * @source
    */
    private async resolveStructuresOnce(): Promise<ReadonlyMap<string, ResolvedStructure>> {
    if (this.resolvedStructures) {
    return this.resolvedStructures;
    }

    const resolved = new Map<string, ResolvedStructure>();
    for (const term of extractAllPositiveTerms(this.parsedQuery.ast)) {
    if (resolved.has(term)) {
    continue;
    }
    const { mode, value } = parseStructurePrefix(term);
    try {
    if (mode === 'smiles' || (mode === 'auto' && looksLikeSmiles(value))) {
    const structure = await resolveSmiles(value);
    if (structure) {
    resolved.set(term, structure);
    }
    continue;
    }
    const termType = detectTermType(value);
    if (termType === 'cas' || termType === 'formula') {
    const identifier = await resolveIdentifierNames(value, termType);
    if (identifier) {
    resolved.set(term, {
    name: identifier.names[0],
    names: identifier.names,
    cas: identifier.cas,
    source: termType === 'cas' ? 'pubchem-cas' : 'pubchem-formula',
    });
    }
    }
    } catch (error) {
    this.logger.warn('Failed to resolve identifier term; skipping', { term, error });
    }
    }

    this.resolvedStructures = resolved;
    return resolved;
    }

    /**
    * Executes the execute() method on all selected suppliers in parallel using async-await-queue.
    * Results are collected and flattened into a single array.
    *
    * @param concurrency - Maximum number of suppliers to process in parallel (default: 3)
    * @returns Promise resolving to an array of all products from all suppliers
    * @example
    * ```typescript
    * const factory = new SupplierFactory("acetone", { limit: 5, controller: new AbortController() });
    * const allProducts = await factory.executeAll(3); // 3 suppliers in parallel
    * console.log(allProducts);
    * ```
    * @source
    */
    public async executeAll(concurrency: number = 3): Promise<P[]> {
    // Resolve any SMILES/structure terms once up front so every instance shares them.
    await this.resolveStructuresOnce();

    // 1. Instantiate supplier classes
    const supplierInstances: SupplierBase<unknown, P>[] = mapDefined(
    Object.entries(suppliers),
    ([supplierClassName, supplierClass]) => {
    if (this.disabledSuppliers.includes(supplierClassName)) return;
    if (!(this.suppliers.length === 0 || this.suppliers.includes(supplierClassName))) return;

    this.logger.debug('Initializing supplier class:', supplierClassName);
    // Trusted static supplier classes; the union of concrete constructors
    // isn't structurally assignable to the generic SupplierConstructor<P>.
    const ConcreteSupplierClass = supplierClass as unknown as SupplierConstructor<P>;
    const instance = new ConcreteSupplierClass(this.query, this.limit, this.controller);
    instance.initCache(
    this.caching,
    this.doNotCacheEmptyResults,
    this.cacheTtlMinutes,
    this.noCacheStatusCodes,
    );
    instance.setFuzzScorerOverride(this.fuzzScorerOverride);
    instance.setSupplierSearchTimeBudgetSec(this.supplierSearchTimeBudgetSec);
    instance.setParsedQuery(this.parsedQuery);
    instance.setFuzzyFilteringDisabled(this.fuzzyFilteringDisabled);
    instance.setResolvedStructures(this.resolvedStructures);
    return instance;
    },
    );

    // 2. Drop suppliers that don't ship to the user's location, then keep only
    // those with granted host permissions.
    const shippableInstances = this.filterByShipping(supplierInstances);
    this.shippingExcludedAll = supplierInstances.length > 0 && shippableInstances.length === 0;
    const permittedInstances = await this.filterByPermissions(shippableInstances);

    // 3. Use async-await-queue for parallel execution
    const queue = new Queue(concurrency, 100);
    const allResults: P[] = [];
    const errors: SupplierExecutionError<P>[] = [];

    const tasks = permittedInstances.map((supplier) =>
    queue.run(async () => {
    try {
    for await (const product of supplier.execute()) {
    const filtered = this.applyRestrictionFilter(product);
    if (filtered !== undefined) {
    allResults.push(filtered);
    }
    }
    } catch (e) {
    this.logger.error('Error executing supplier', { error: e, supplier });
    incrementParseError(supplier.supplierName);
    if (!isAbortError(e)) errors.push({ error: e, supplier });
    }
    }),
    );

    await Promise.all(tasks);

    // Aggregate any per-supplier failures into the shared error buffer.
    this.reportExecutionErrors(errors);
    return allResults;
    }

    /**
    * Streams products from all selected suppliers as soon as each supplier's execute() resolves.
    * Uses async-await-queue for concurrency control and yields products as they are available.
    *
    * @param concurrency - Maximum number of suppliers to process in parallel (default: 3)
    * @returns AsyncGenerator yielding products from all suppliers as soon as they are ready
    * @example
    * ```typescript
    * for await (const product of factory.executeAllStream(3)) {
    * console.log(product);
    * }
    * ```
    * @source
    */
    public async *executeAllStream(concurrency: number = 3): AsyncGenerator<P, void, undefined> {
    // Resolve any SMILES/structure terms once up front so every instance shares them.
    await this.resolveStructuresOnce();

    const supplierInstances: SupplierBase<unknown, P>[] = mapDefined(
    Object.entries(suppliers),
    ([supplierClassName, supplierClass]) => {
    if (this.disabledSuppliers.includes(supplierClassName)) return;
    if (!(this.suppliers.length === 0 || this.suppliers.includes(supplierClassName))) return;

    this.logger.debug('Initializing supplier class', { supplierClassName });
    // Trusted static supplier classes; the union of concrete constructors
    // isn't structurally assignable to the generic SupplierConstructor<P>.
    const ConcreteSupplierClass = supplierClass as unknown as SupplierConstructor<P>;
    const instance = new ConcreteSupplierClass(this.query, this.limit, this.controller);
    instance.initCache(
    this.caching,
    this.doNotCacheEmptyResults,
    this.cacheTtlMinutes,
    this.noCacheStatusCodes,
    );
    instance.setFuzzScorerOverride(this.fuzzScorerOverride);
    instance.setSupplierSearchTimeBudgetSec(this.supplierSearchTimeBudgetSec);
    instance.setParsedQuery(this.parsedQuery);
    instance.setFuzzyFilteringDisabled(this.fuzzyFilteringDisabled);
    instance.setResolvedStructures(this.resolvedStructures);
    return instance;
    },
    );

    // Drop suppliers that don't ship to the user's location, then keep only
    // those with granted host permissions.
    const shippableInstances = this.filterByShipping(supplierInstances);
    this.shippingExcludedAll = supplierInstances.length > 0 && shippableInstances.length === 0;
    const permittedInstances = await this.filterByPermissions(shippableInstances);
    const queue = new Queue(concurrency, 100);

    const channel: P[] = [];
    const errors: SupplierExecutionError<P>[] = [];
    let doneCount = 0;

    permittedInstances.forEach((supplier) => {
    queue.run(async () => {
    try {
    const iterator = supplier.execute();
    for await (const product of iterator) {
    const filtered = this.applyRestrictionFilter(product);
    if (filtered !== undefined) {
    channel.push(filtered);
    }
    }
    } catch (e) {
    this.logger.error('Error executing supplier', { error: e, supplier });
    incrementParseError(supplier.supplierName);
    if (!isAbortError(e)) errors.push({ error: e, supplier });
    } finally {
    doneCount++;
    }
    });
    });

    // Yield results as they come in, until all suppliers are done and the channel is empty
    while (doneCount < permittedInstances.length || channel.length > 0) {
    if (channel.length > 0) {
    yield channel.shift()!;
    } else {
    await sleep(25);
    }
    }

    // All suppliers have settled; partial results were already streamed, so record
    // (rather than throw) any failures as one AggregateError for bug reports.
    this.reportExecutionErrors(errors);
    }

    /**
    * Records the per-supplier exceptions from a search run as a single
    * {@link AggregateError} in the shared error buffer, so a later bug report can
    * include them, and stashes them on {@link executionErrors}. Aborts are
    * excluded by the callers. A no-op when nothing failed.
    * @param errors - The per-supplier execution errors collected during the run.
    * @returns Nothing.
    * @example
    * ```ts
    * this.reportExecutionErrors([{ error: new Error("boom"), supplier }]);
    * ```
    * @source
    */
    private reportExecutionErrors(errors: SupplierExecutionError<P>[]): void {
    this.executionErrors = errors;
    if (errors.length === 0) return;

    const names = errors.map((e) => e.supplier.supplierName).join(', ');
    const aggregate = new AggregateError(
    errors.map((e) => e.error),
    `${errors.length} supplier(s) failed during search: ${names}`,
    );
    void recordException(aggregate, 'search');
    }
    }

    Type Parameters

    Index

    Constructors

    Methods

    • Get the list of available supplier module names. Use these names when specifying which suppliers to query in the constructor.

      Returns (
          | "SupplierAladdinSci"
          | "SupplierAlboChemicals"
          | "SupplierAlchemieLabs"
          | "SupplierAllianceChemical"
          | "SupplierAmarisChemicalSolutions"
          | "SupplierAmbeed"
          | "SupplierAsesChem"
          | "SupplierBioFuranChem"
          | "SupplierBVV"
          | "SupplierCarolina"
          | "SupplierCarolinaChemical"
          | "SupplierChemsavers"
          | "SupplierConsolidatedChemical"
          | "SupplierDailyBioUSA"
          | "SupplierFtfScientific"
          | "SupplierGoldAndSilverTesting"
          | "SupplierHimedia"
          | "SupplierInnovatingScience"
          | "SupplierLaballey"
          | "SupplierLabChem"
          | "SupplierLaboratoriumDiscounter"
          | "SupplierLeroChem"
          | "SupplierLibertySci"
          | "SupplierLiMac"
          | "SupplierLoudwolf"
          | "SupplierMacklin"
          | "SupplierOnyxmet"
          | "SupplierOrbitNaturalProductDerivatives"
          | "SupplierS3Chemicals"
          | "SupplierScienceLab"
          | "SupplierSynthetika"
          | "SupplierTheLabStockroom"
          | "SupplierVWR"
          | "SupplierWarchem"
      )[]

      Array of supplier class names that can be queried

      const suppliers = SupplierFactory.supplierList();
      // Returns: ["SupplierCarolina", "SupplierLaballey", "SupplierBioFuranChem", ...]

      // Use these names to create a targeted factory
      const factory = new SupplierFactory("acid", { controller, suppliers });
        public static supplierList(): Array<SupplierClassName> {
      return Object.keys(suppliers) as unknown as Array<SupplierClassName>;
      }
    • Type guard narrowing an arbitrary string to a known SupplierClassName. Use at runtime boundaries (persisted values, UI inputs) where a plain string needs to be confirmed as one of the barrel-exported supplier names before it's stored or searched.

      Parameters

      • value: string

        Candidate string to test.

      Returns value is
          | "SupplierAladdinSci"
          | "SupplierAlboChemicals"
          | "SupplierAlchemieLabs"
          | "SupplierAllianceChemical"
          | "SupplierAmarisChemicalSolutions"
          | "SupplierAmbeed"
          | "SupplierAsesChem"
          | "SupplierBioFuranChem"
          | "SupplierBVV"
          | "SupplierCarolina"
          | "SupplierCarolinaChemical"
          | "SupplierChemsavers"
          | "SupplierConsolidatedChemical"
          | "SupplierDailyBioUSA"
          | "SupplierFtfScientific"
          | "SupplierGoldAndSilverTesting"
          | "SupplierHimedia"
          | "SupplierInnovatingScience"
          | "SupplierLaballey"
          | "SupplierLabChem"
          | "SupplierLaboratoriumDiscounter"
          | "SupplierLeroChem"
          | "SupplierLibertySci"
          | "SupplierLiMac"
          | "SupplierLoudwolf"
          | "SupplierMacklin"
          | "SupplierOnyxmet"
          | "SupplierOrbitNaturalProductDerivatives"
          | "SupplierS3Chemicals"
          | "SupplierScienceLab"
          | "SupplierSynthetika"
          | "SupplierTheLabStockroom"
          | "SupplierVWR"
          | "SupplierWarchem"

      True (and narrows value to SupplierClassName) when it names an exported supplier.

      ["SupplierCarolina", "Nope"].filter(SupplierFactory.isSupplierClassName);
      // => ["SupplierCarolina"]
        public static isSupplierClassName(value: string): value is SupplierClassName {
      return SupplierFactory.supplierList().some((name) => name === value);
      }
    • Get a map of supplier module names to their display names.

      Returns Record<string, string>

      Record mapping supplier class names to their supplierName property

        public static supplierDisplayNames(): Record<string, string> {
      return Object.fromEntries(
      // eslint-disable-next-line @typescript-eslint/naming-convention
      Object.entries(suppliers).map(([key, SupplierClass]) => [
      // Read the class's static supplierName directly — no instance needed.
      key,
      (SupplierClass as unknown as { supplierName: string }).supplierName,
      ]),
      );
      }
    • Get a map of supplier class names to whether they ship to the given location. Creates throwaway instances and delegates to SupplierBase.shipsToCountry, so it applies the same shipsTo/scope heuristic used at search time. Lets the UI grey out suppliers that won't ship to the user.

      Parameters

      • location: valueof

        The user's location as an ISO 3166-1 alpha-2 country code.

      Returns Record<string, boolean>

      Record mapping supplier class names to a ships-to boolean.

      const map = SupplierFactory.supplierShipsTo("US");
      // { SupplierCarolina: true, SupplierWarchem: false, ... }
        public static supplierShipsTo(location: CountryCode): Record<string, boolean> {
      return Object.fromEntries(
      // eslint-disable-next-line @typescript-eslint/naming-convention
      Object.entries(suppliers).map(([key, SupplierClass]) => {
      // Read the class's static shipping metadata directly — no instance needed.
      const meta = SupplierClass as unknown as SupplierStaticMeta;
      return [key, SupplierBase.shipsToCountryStatic(meta, location)];
      }),
      );
      }
    • Get a map of supplier class names to their home country and shipping scope, the same fields stamped onto products. Lets the UI (and the search) reason about which suppliers are compatible with the drawer's shipping/country filters without querying them. Reads each supplier's static metadata, so no instances are created.

      Returns Record<string, { country: CountryCode; shipping: ShippingRange }>

      Record mapping supplier class names to { country, shipping }.

      const meta = SupplierFactory.supplierShippingMeta();
      // { SupplierCarolina: { country: "US", shipping: "domestic" }, ... }
        public static supplierShippingMeta(): Record<
      string,
      { country: CountryCode; shipping: ShippingRange }
      > {
      return Object.fromEntries(
      // eslint-disable-next-line @typescript-eslint/naming-convention
      Object.entries(suppliers).map(([key, SupplierClass]) => {
      const meta = SupplierClass as unknown as SupplierStaticMeta;
      return [key, { country: meta.country, shipping: meta.shipping }];
      }),
      );
      }
    • Get a map of supplier class names to their required host origins, read from each supplier's static requiredHosts (derived from its static baseURL/apiURL) — no instances are created.

      Returns Record<string, string[]>

      Record mapping supplier class names to their requiredHosts arrays

        public static supplierRequiredHosts(): Record<string, string[]> {
      return Object.fromEntries(
      // eslint-disable-next-line @typescript-eslint/naming-convention
      Object.entries(suppliers).map(([key, SupplierClass]) => [
      key,
      (SupplierClass as unknown as { requiredHosts: string[] }).requiredHosts,
      ]),
      );
      }
    • Filters supplier instances to only those whose required host permissions are already granted. Uses chrome.permissions.contains() which is a passive check — it does not prompt the user. Permission granting should be handled separately in a UI flow (e.g., a settings page).

      Type Parameters

      Parameters

      • instances: SupplierBase<unknown, P>[]

        Array of supplier instances to check

      Returns Promise<SupplierBase<unknown, P>[]>

      Filtered array of suppliers with granted permissions

        private async filterByPermissions<P extends Product>(
      instances: SupplierBase<unknown, P>[],
      ): Promise<SupplierBase<unknown, P>[]> {
      const results = await Promise.all(
      instances.map(async (instance) => {
      if (instance.requiredHosts.length === 0) return { instance, granted: true };
      try {
      const granted = await chrome.permissions.contains({ origins: instance.requiredHosts });
      if (!granted) {
      this.logger.warn('Permission check failed for supplier', {
      supplier: instance.supplierName,
      requiredHosts: instance.requiredHosts,
      });
      }
      return { instance, granted };
      } catch (e) {
      this.logger.error('Permission check failed for supplier', {
      supplier: instance.supplierName,
      error: e,
      });
      return { instance, granted: false };
      }
      }),
      );
      return results.filter((r) => r.granted).map((r) => r.instance);
      }
    • Filters supplier instances to only those that ship to the user's location. A no-op (returns all instances) when shipping filtering is disabled or no location is set. Synchronous — unlike filterByPermissions, it only reads in-memory supplier metadata via SupplierBase.shipsToCountry.

      Type Parameters

      Parameters

      • instances: SupplierBase<unknown, P>[]

        Array of supplier instances to check.

      Returns SupplierBase<unknown, P>[]

      The subset of instances that ship to the user's location.

        private filterByShipping<P extends Product>(
      instances: SupplierBase<unknown, P>[],
      ): SupplierBase<unknown, P>[] {
      if (!this.excludeNonShippingSuppliers || !this.location) {
      return instances;
      }
      const location = this.location as CountryCode;
      return instances.filter((instance) => instance.shipsToCountry(location));
      }
    • Applies per-product purchase-restriction filtering to a single result. A no-op (returns the product unchanged) when hideRestrictedProducts is off; otherwise delegates to filterRestrictedProduct, which prunes options the user can't buy and returns undefined when the whole product is unbuyable.

      Type Parameters

      Parameters

      • product: Q

        The product to filter.

      Returns undefined | Q

      The product (possibly with restricted variants pruned), or undefined to drop it.

        private applyRestrictionFilter<Q extends Product>(product: Q): Q | undefined {
      if (this.hideRestrictedProducts !== true) {
      return product;
      }
      return filterRestrictedProduct(product, this.location);
      }
    • Resolves every chemical-identifier term in the query — SMILES/structure, CAS number, or molecular formula — to its chemical identifiers (name, CAS, InChIKey) exactly once, memoizing the result on the factory so it is shared with every supplier instead of each supplier re-hitting the network.

      Only positive (non-negated) identifier leaf terms are resolved: a structure (via looksLikeSmiles or an explicit smiles:/inchikey: prefix) via resolveSmiles, or a CAS/formula (via detectTermType) via resolveIdentifierNames. Plain name queries resolve nothing and make no network calls. Each unique term is resolved once; failures are logged and skipped so a dead resolver never blocks the search. Every supplier then swaps an identifier it can't search for the resolved name (see SupplierBase.effectiveQuery).

      Returns Promise<ReadonlyMap<string, ResolvedStructure>>

      A map of raw search term → resolved structure (empty when none apply).

      // query "CCO"      -> Map { "CCO"      => { name: "ethanol", cas: ["64-17-5"], ... } }
      // query "Na6O18P6" -> Map { "Na6O18P6" => { name: "Hexasodium hexametaphosphate", ... } }
      await factory.resolveStructuresOnce();
        private async resolveStructuresOnce(): Promise<ReadonlyMap<string, ResolvedStructure>> {
      if (this.resolvedStructures) {
      return this.resolvedStructures;
      }

      const resolved = new Map<string, ResolvedStructure>();
      for (const term of extractAllPositiveTerms(this.parsedQuery.ast)) {
      if (resolved.has(term)) {
      continue;
      }
      const { mode, value } = parseStructurePrefix(term);
      try {
      if (mode === 'smiles' || (mode === 'auto' && looksLikeSmiles(value))) {
      const structure = await resolveSmiles(value);
      if (structure) {
      resolved.set(term, structure);
      }
      continue;
      }
      const termType = detectTermType(value);
      if (termType === 'cas' || termType === 'formula') {
      const identifier = await resolveIdentifierNames(value, termType);
      if (identifier) {
      resolved.set(term, {
      name: identifier.names[0],
      names: identifier.names,
      cas: identifier.cas,
      source: termType === 'cas' ? 'pubchem-cas' : 'pubchem-formula',
      });
      }
      }
      } catch (error) {
      this.logger.warn('Failed to resolve identifier term; skipping', { term, error });
      }
      }

      this.resolvedStructures = resolved;
      return resolved;
      }
    • Executes the execute() method on all selected suppliers in parallel using async-await-queue. Results are collected and flattened into a single array.

      Parameters

      • concurrency: number = 3

        Maximum number of suppliers to process in parallel (default: 3)

      Returns Promise<P[]>

      Promise resolving to an array of all products from all suppliers

      const factory = new SupplierFactory("acetone", { limit: 5, controller: new AbortController() });
      const allProducts = await factory.executeAll(3); // 3 suppliers in parallel
      console.log(allProducts);
        public async executeAll(concurrency: number = 3): Promise<P[]> {
      // Resolve any SMILES/structure terms once up front so every instance shares them.
      await this.resolveStructuresOnce();

      // 1. Instantiate supplier classes
      const supplierInstances: SupplierBase<unknown, P>[] = mapDefined(
      Object.entries(suppliers),
      ([supplierClassName, supplierClass]) => {
      if (this.disabledSuppliers.includes(supplierClassName)) return;
      if (!(this.suppliers.length === 0 || this.suppliers.includes(supplierClassName))) return;

      this.logger.debug('Initializing supplier class:', supplierClassName);
      // Trusted static supplier classes; the union of concrete constructors
      // isn't structurally assignable to the generic SupplierConstructor<P>.
      const ConcreteSupplierClass = supplierClass as unknown as SupplierConstructor<P>;
      const instance = new ConcreteSupplierClass(this.query, this.limit, this.controller);
      instance.initCache(
      this.caching,
      this.doNotCacheEmptyResults,
      this.cacheTtlMinutes,
      this.noCacheStatusCodes,
      );
      instance.setFuzzScorerOverride(this.fuzzScorerOverride);
      instance.setSupplierSearchTimeBudgetSec(this.supplierSearchTimeBudgetSec);
      instance.setParsedQuery(this.parsedQuery);
      instance.setFuzzyFilteringDisabled(this.fuzzyFilteringDisabled);
      instance.setResolvedStructures(this.resolvedStructures);
      return instance;
      },
      );

      // 2. Drop suppliers that don't ship to the user's location, then keep only
      // those with granted host permissions.
      const shippableInstances = this.filterByShipping(supplierInstances);
      this.shippingExcludedAll = supplierInstances.length > 0 && shippableInstances.length === 0;
      const permittedInstances = await this.filterByPermissions(shippableInstances);

      // 3. Use async-await-queue for parallel execution
      const queue = new Queue(concurrency, 100);
      const allResults: P[] = [];
      const errors: SupplierExecutionError<P>[] = [];

      const tasks = permittedInstances.map((supplier) =>
      queue.run(async () => {
      try {
      for await (const product of supplier.execute()) {
      const filtered = this.applyRestrictionFilter(product);
      if (filtered !== undefined) {
      allResults.push(filtered);
      }
      }
      } catch (e) {
      this.logger.error('Error executing supplier', { error: e, supplier });
      incrementParseError(supplier.supplierName);
      if (!isAbortError(e)) errors.push({ error: e, supplier });
      }
      }),
      );

      await Promise.all(tasks);

      // Aggregate any per-supplier failures into the shared error buffer.
      this.reportExecutionErrors(errors);
      return allResults;
      }
    • Streams products from all selected suppliers as soon as each supplier's execute() resolves. Uses async-await-queue for concurrency control and yields products as they are available.

      Parameters

      • concurrency: number = 3

        Maximum number of suppliers to process in parallel (default: 3)

      Returns AsyncGenerator<P, void, undefined>

      AsyncGenerator yielding products from all suppliers as soon as they are ready

      for await (const product of factory.executeAllStream(3)) {
      console.log(product);
      }
        public async *executeAllStream(concurrency: number = 3): AsyncGenerator<P, void, undefined> {
      // Resolve any SMILES/structure terms once up front so every instance shares them.
      await this.resolveStructuresOnce();

      const supplierInstances: SupplierBase<unknown, P>[] = mapDefined(
      Object.entries(suppliers),
      ([supplierClassName, supplierClass]) => {
      if (this.disabledSuppliers.includes(supplierClassName)) return;
      if (!(this.suppliers.length === 0 || this.suppliers.includes(supplierClassName))) return;

      this.logger.debug('Initializing supplier class', { supplierClassName });
      // Trusted static supplier classes; the union of concrete constructors
      // isn't structurally assignable to the generic SupplierConstructor<P>.
      const ConcreteSupplierClass = supplierClass as unknown as SupplierConstructor<P>;
      const instance = new ConcreteSupplierClass(this.query, this.limit, this.controller);
      instance.initCache(
      this.caching,
      this.doNotCacheEmptyResults,
      this.cacheTtlMinutes,
      this.noCacheStatusCodes,
      );
      instance.setFuzzScorerOverride(this.fuzzScorerOverride);
      instance.setSupplierSearchTimeBudgetSec(this.supplierSearchTimeBudgetSec);
      instance.setParsedQuery(this.parsedQuery);
      instance.setFuzzyFilteringDisabled(this.fuzzyFilteringDisabled);
      instance.setResolvedStructures(this.resolvedStructures);
      return instance;
      },
      );

      // Drop suppliers that don't ship to the user's location, then keep only
      // those with granted host permissions.
      const shippableInstances = this.filterByShipping(supplierInstances);
      this.shippingExcludedAll = supplierInstances.length > 0 && shippableInstances.length === 0;
      const permittedInstances = await this.filterByPermissions(shippableInstances);
      const queue = new Queue(concurrency, 100);

      const channel: P[] = [];
      const errors: SupplierExecutionError<P>[] = [];
      let doneCount = 0;

      permittedInstances.forEach((supplier) => {
      queue.run(async () => {
      try {
      const iterator = supplier.execute();
      for await (const product of iterator) {
      const filtered = this.applyRestrictionFilter(product);
      if (filtered !== undefined) {
      channel.push(filtered);
      }
      }
      } catch (e) {
      this.logger.error('Error executing supplier', { error: e, supplier });
      incrementParseError(supplier.supplierName);
      if (!isAbortError(e)) errors.push({ error: e, supplier });
      } finally {
      doneCount++;
      }
      });
      });

      // Yield results as they come in, until all suppliers are done and the channel is empty
      while (doneCount < permittedInstances.length || channel.length > 0) {
      if (channel.length > 0) {
      yield channel.shift()!;
      } else {
      await sleep(25);
      }
      }

      // All suppliers have settled; partial results were already streamed, so record
      // (rather than throw) any failures as one AggregateError for bug reports.
      this.reportExecutionErrors(errors);
      }
    • Records the per-supplier exceptions from a search run as a single AggregateError in the shared error buffer, so a later bug report can include them, and stashes them on executionErrors. Aborts are excluded by the callers. A no-op when nothing failed.

      Parameters

      Returns void

      Nothing.

      this.reportExecutionErrors([{ error: new Error("boom"), supplier }]);
      
        private reportExecutionErrors(errors: SupplierExecutionError<P>[]): void {
      this.executionErrors = errors;
      if (errors.length === 0) return;

      const names = errors.map((e) => e.supplier.supplierName).join(', ');
      const aggregate = new AggregateError(
      errors.map((e) => e.error),
      `${errors.length} supplier(s) failed during search: ${names}`,
      );
      void recordException(aggregate, 'search');
      }

    Properties

    query: string
    controller: AbortController
    suppliers: string[]
    disabledSuppliers: string[]
    limit: number = search.defaultResultsLimitPerSupplier
    caching: boolean
    doNotCacheEmptyResults: boolean
    cacheTtlMinutes: number
    noCacheStatusCodes: number[]
    fuzzScorerOverride?: string
    supplierSearchTimeBudgetSec?: number
    parsedQuery: ParsedSearchQuery
    fuzzyFilteringDisabled: boolean
    location?: string
    excludeNonShippingSuppliers: boolean
    hideRestrictedProducts: boolean
    resolvedStructures?: ReadonlyMap<string, ResolvedStructure>
    logger: Logger
    shippingExcludedAll: boolean = false
    executionErrors: SupplierExecutionError<P>[] = []