Factory class for querying all suppliers.
Value to query for
Factory configuration; see SupplierFactoryOptions. controller is
required, everything else is optional with sensible defaults.
StaticsupplierGet the list of available supplier module names. Use these names when specifying which suppliers to query in the constructor.
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>;
}
StaticisType 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.
Candidate string to test.
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);
}
StaticsupplierGet a map of supplier module names to their display names.
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,
]),
);
}
StaticsupplierGet 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.
The user's location as an ISO 3166-1 alpha-2 country code.
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)];
}),
);
}
StaticsupplierGet 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.
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 }];
}),
);
}
StaticsupplierGet 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.
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,
]),
);
}
PrivatefilterFilters 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).
Array of supplier instances to check
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);
}
PrivatefilterFilters 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.
Array of supplier instances to check.
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));
}
PrivateapplyApplies 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.
The product to filter.
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);
}
PrivateresolveResolves 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).
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.
Maximum number of suppliers to process in parallel (default: 3)
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.
Maximum number of suppliers to process in parallel (default: 3)
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);
}
PrivatereportRecords 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.
The per-supplier execution errors collected during the run.
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');
}
PrivatequeryPrivatecontrollerPrivatesuppliersPrivatedisabledPrivatelimitPrivatecachingPrivatedoPrivatecachePrivatenoPrivate OptionalfuzzPrivate OptionalsupplierPrivateparsedPrivatefuzzyPrivate OptionallocationPrivateexcludePrivatehidePrivate OptionalresolvedPrivatelogger
Factory class for querying multiple chemical suppliers simultaneously. This class provides a unified interface to search across multiple supplier implementations.
Example
Source