npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@abuhasanrumi/rules-feed-engine

v1.1.12

Published

Shared rules-feed engine for Dynamatic widget repositories.

Readme

@abuhasanrumi/rules-feed-engine

A shared TypeScript rules-feed engine for Dynamatic widget and cart storefront integrations. It evaluates IF/THEN rule logic across product feeds, plans and batches all network fetches, assembles and deduplicates the final product list, reports API errors automatically with rate limiting, and provides a cart output adapter and full pipeline diagnostics.

Current version: 1.1.5 — Published to npm as an ES module. No runtime dependencies; devDependencies contains only typescript.


Table of Contents

  1. Tech Stack
  2. Installation
  3. Quick Start
  4. Module Structure
  5. Architecture Overview
  6. Input Shape
  7. Core Concepts
  8. IF Logic Evaluation
  9. Return Sources
  10. Batch API Call Behavior
  11. Source Caching and Deduplication
  12. Product Assembly
  13. Exclusion Handling
  14. Output Shape
  15. Backward Compatibility
  16. Error Reporting
  17. Diagnostics
  18. Debug Logging
  19. Key Types and Interfaces
  20. Public API Reference
  21. End-to-End Example
  22. Development
  23. Version History

Tech Stack

  • Language: TypeScript 5.x, compiled to ES module output ("type": "module")
  • Runtime / build tool: Bun
  • Package registry: npm, published as @abuhasanrumi/rules-feed-engine (scoped public package)
  • Entry points: dist/index.js (main), dist/index.d.ts (types)
  • Runtime dependencies: none

Installation

npm install @abuhasanrumi/rules-feed-engine
# or
bun add @abuhasanrumi/rules-feed-engine

The package is an ES module. main points to dist/index.js and types to dist/index.d.ts.


Quick Start

import { runRulesFeedEngine, toCartRulesFeedOutput } from '@abuhasanrumi/rules-feed-engine';

const result = await runRulesFeedEngine({
  feeds,
  context: {
    inputProducts: [...],
    cart: { items: [], item_count: 0 },
    customer: {},
    geo: {},
  },
  runtime: {
    channel: 'webpage',                           // or 'cart'
    shopDomain: 'example.myshopify.com',
    storefrontAccessToken: 'public-storefront-token',
    shopifyRoutesRoot: '/',
    metadata: {                                   // optional — included in error reports
      widget_id: 123,
      campaign_id: 456,
    },
  },
  config: { includeDiagnostics: true },
  cache: {},    // pass the same object across runs to reuse cached results
  signal,       // optional AbortSignal to cancel in-flight requests
});

// Webpage widget — use result.products directly
const products = result.products;

// Cart upsell — convert to cart output shape
const cartPayload = toCartRulesFeedOutput(result);

Module Structure

Each file in src/ has a single responsibility:

| File | Purpose | |---|---| | src/index.ts | Package entry point; re-exports all public types, functions, and constants | | src/types.ts | All TypeScript interfaces and type aliases (RulesFeedRuntime, NormalizedContext, RulesFeedEngineResult, etc.) | | src/runRulesFeedEngine.ts | Main entry point; orchestrates the full pipeline; deduplicates concurrent calls per channel + feedId | | src/validation.ts | Applies runtime defaults, normalizes feeds, initializes cache maps and diagnostics | | src/normalizeContext.ts | Builds NormalizedContext — product id/title/vendor/type/handle arrays and precomputed cart totals from raw context | | src/evaluateRules.ts | Iterates feeds and rules; checks for invalid logic types; calls evaluateLogic; respects isMatched early-exit | | src/evaluateLogic.ts | Evaluates a single RulesFeedLogic node; memoizes results per name + type + value; exports comparison primitives | | src/normalizeReturns.ts | Normalizes return name aliases; classifies returns as product/metadata/API; exports METADATA_RETURN_NAMES | | src/returnPlanning.ts | Splits returns into local and API-backed; groups API returns per feed into one batch request; computes cache keys | | src/recommendationSeed.ts | Resolves the recommendation seed (cart items, current page product, or input products) per feed configuration | | src/requirements.ts | Detects which logic names require pre-fetched product details; exports PRODUCT_INFO_LOGIC_NAMES and getRequiredProductDetails | | src/sourceFetching.ts | Executes all source fetches (Shopify Storefront GraphQL, Shopify recommendations, Dynamatic API); handles caching, in-flight deduplication, and error reporting | | src/fetchWithTimeout.ts | Low-level fetch wrapper; uses XHR in browsers and native fetch in Node/SSR; enforces requestTimeoutMs; supports AbortSignal | | src/productAssembly.ts | Builds final product lists from planned rules and fetch results; applies exclusions, variant filtering, limits, and discounts | | src/postProcessing.ts | Compiles feed exclusions into fast lookup sets; applies them to local source products; globally deduplicates by product_id | | src/errorReporter.ts | Posts API failure details to the Dynamatic error log endpoint; rate-limited per shop + source + error class (5-minute cooldown via localStorage) | | src/adapters.ts | Converts RulesFeedEngineResult to CartRulesFeedOutput shape | | src/logger.ts | Color-coded browser console logger; gated behind localStorage.getItem('DYN_RULES_FEED_ENGINE_DEBUG') === 'true' | | src/utils.ts | Shared utilities: normalizeId, getProductId, safeHash, stableStringify, formatDiscount, markTiming, and engine constants |


Architecture Overview

runRulesFeedEngine runs each input through a linear pipeline. Each stage has one job.

validateInput(input)
  ↓
normalizeContext(context, channel)
  ↓
evaluateRules(feeds, context)            — IF logic matrix
  ↓
planReturns(passedRules, context)        — source keys, batch grouping
  ↓
fetchSources(sourceRequests, ...)        — network calls with cache and timeout
  ↓
assembleProducts(plannedRules, results)  — limits, discounts, usedApiKeys guard
  ↓
dedupeProducts(products)                 — global dedupe by product_id
  ↓
return RulesFeedEngineResult

| Stage | File | What it does | |---|---|---| | Validation | src/validation.ts | Applies runtime defaults, normalizes feeds, initializes cache maps and diagnostics | | Context normalization | src/normalizeContext.ts | Builds product id/title/vendor/type/handle arrays and internal caches from raw context | | IF evaluation | src/evaluateRules.ts, src/evaluateLogic.ts | Evaluates each rule's logics matrix; channel-aware for subscription and variant title checks | | Return planning | src/returnPlanning.ts, src/normalizeReturns.ts | Splits product vs metadata returns; dedupes source keys; groups all API-backed returns from the same feed into one request | | Fetching | src/sourceFetching.ts, src/fetchWithTimeout.ts | Runs source requests with timeout, cache, in-flight dedupe, and AbortSignal support. Uses XHR in browsers; falls back to native fetch in Node/SSR. | | Product assembly | src/productAssembly.ts | Adds batch API products once per rule group; applies exclusions; enforces limits; attaches discounts | | Post-processing | src/postProcessing.ts | Applies compiled feed exclusions to local sources; globally dedupes by product id | | Error reporting | src/errorReporter.ts | Posts API failure details to the error log endpoint; rate-limited per shop + source + error class | | Adapters | src/adapters.ts | Converts engine result to cart output shape |


Input Shape

interface RulesFeedEngineInput {
  feeds: RulesFeedFeed[];
  context: RulesFeedContext;
  runtime: RulesFeedRuntime;
  config?: RulesFeedEngineConfig;
  cache?: RulesFeedSourceCache;
  signal?: AbortSignal;
}

Feeds

interface RulesFeedFeed {
  id: string | number;
  feedId?: string | number;
  rules: RulesFeedRule[];
  exclusions: RulesFeedExclusion[];
  recommendation_seed: RecommendationSeed;  // 'auto' | 'cart' | 'currentPageProduct'
  filter_input_product?: boolean;
  return_ordering?: ReturnOrdering;         // 'sequential' | 'round-robin' | 'shuffle'
  [key: string]: unknown;
}

Validation normalizes feeds before evaluation:

  • Null rules are removed.
  • Missing exclusions becomes [].
  • Invalid recommendation_seed values become 'auto'.
  • filter_input_product is coerced to boolean.

recommendation_seed — controls which products seed Shopify and Dynamatic recommendation calls:

| Value | Seeding behavior | |---|---| | 'auto' | Cart channel: use lineItems. Webpage with currentPageProduct: use it. Webpage without: fall back to lineItems, then inputProducts. | | 'cart' | Always use context.lineItems. | | 'currentPageProduct' | Always use context.currentPageProduct. |

filter_input_product — when true, products that appear in any products IF logic in this feed's rules are excluded from local return sources. Does not affect API return sources.

Rules

interface RulesFeedRule {
  id: string | number;      // rules without id are skipped
  name?: string;
  logics: RulesFeedLogic[][];
  returns: RulesFeedReturn[];
  isMatched?: boolean;
  returnOrdering?: string;   // forwarded to Dynamatic API; defaults to 'shuffle'
  [key: string]: unknown;
}
  • A rule without id is skipped with diagnostic reason missing-rule-id.
  • A rule with a blank or null type in any logic is skipped with reason invalid-logic-type (except customer-logged-in and equals-anything, which are exempt).
  • isMatched: true stops further rule evaluation in the current feed after this rule passes. Other feeds continue normally.
  • returnOrdering is forwarded as return_ordering in the API payload. Defaults to 'shuffle' when absent.

Returns

interface RulesFeedReturn {
  name: string;
  type: string;
  value: unknown;
  mode?: 'include' | 'exclude';        // defaults to 'include' when sent to the API
  filters?: RulesFeedReturnFilter[];   // forwarded to API as-is; not evaluated engine-side
  [key: string]: unknown;
}

interface RulesFeedReturnFilter {
  id: string;
  name: string;
  type: string;
  value: unknown;
  products?: RulesFeedProduct[];
}

Return name normalization — applied before source routing and API payload construction:

| Input name | Normalized to | |---|---| | shopify_recommendation | shopify-recommendation | | dhopify_recommendation | shopify-recommendation | | dynamatic_recommendation | dynamatic-recommendation | | specific-tag-products | products-with-tags | | anything else | unchanged |

mode — tells the Dynamatic backend whether this return is an inclusion or exclusion. Old returns without mode default to 'include'.

filters — per-return filter constraints forwarded to the API unchanged. Not evaluated engine-side. Old returns without filters have the field omitted from the JSON.

Metadata return names — recognized as metadata, not product sources:

| Name | Engine behavior | |---|---| | product-limit | Integer cap on products from this rule. Default is 10. | | title | Sets customization.title. Last matched rule wins. | | subtitle | Sets customization.subtitle. Last matched rule wins. | | hide-widget | Sets customization.hideWidget when value is true or 'true'. | | product-discount | Formatted and attached to every product from this rule as discount and productDiscount. percentage type appends %; other types use String(value). | | exclude-specific-product | Applied engine-side to shopify-recommendation results only. Removes matched product ids, handles, or specific variant ids. | | shuffle | Recognized as metadata; ordering is backend-controlled. No engine-side effect. |

Context

interface RulesFeedContext {
  inputProducts?: RulesFeedProduct[];
  currentPageProduct?: RulesFeedProduct | null;
  selectedVariantId?: string | number;
  cart: RulesFeedCart;          // required
  lineItems?: RulesFeedCartItem[];
  customer: RulesFeedCustomer;  // required
  geo: RulesFeedGeo;            // required
  url?: string;
  orderTags?: string[];
  now?: Date | string;
  vehicleCodes?: string[];
}

| Field | Default | Used for | |---|---|---| | inputProducts | lineItems mapped to products when empty | IF logic evaluation (tags, collections, vendor, type, handle, inventory, metafields) | | lineItems | context.lineItems \|\| context.cart?.items \|\| [] | Dynamatic API lineitems, cart recommendation seed, subscription logic (cart channel) | | currentPageProduct | null | currentPageProduct returns, recommendation seed (webpage channel) | | selectedVariantId | undefined | any-selected-variant-title / each-selected-variant-title logic on webpage channel | | cart | — | cart-subtotal, cart-item-count, cart-line-count logic; API payload cartData | | customer | — | Customer tag, order count, spend, and login state logic | | geo | — | Country code and country name logic | | url | '' | URL text match logic | | orderTags | [] | Order tag logic | | now | new Date() | Passed to new Date(context.now) for date-related evaluations | | vehicleCodes | [] | Merged with runtime.vehicleCodes (runtime takes precedence); sent to Dynamatic API as dynamatic-metafield-recommendation when non-empty |

Cart subtotal: cart.totalPrice and cart.total_price are treated as cents and divided by 100 for cart-subtotal logic.

Runtime

interface RulesFeedRuntime {
  channel?: 'webpage' | 'cart';
  shopDomain?: string;
  storefrontAccessToken?: string;
  shopifyRoutesRoot?: string;
  recommendationApiUrl?: string;
  recommendationApiUrlV2?: string;
  recommendationPublicKey?: string;
  requestTimeoutMs?: number;
  useRecommendationV2ForShops?: string[];
  storefrontCollectionsPageSize?: number;  // default 250, clamped to 1–250
  storefrontVariantsPageSize?: number;     // default 249, clamped to 1–250
  sourceCacheMaxEntries?: number;          // default 50
  vehicleCodes?: string[];
  metadata?: Record<string, unknown>;
  [key: string]: unknown;
}

Runtime defaults applied during validateInput:

| Field | Default | |---|---| | recommendationApiUrl | 'https://recov2.dynamatics.app/v2' | | recommendationApiUrlV2 | 'https://recov2.dynamatics.app/v2' | | requestTimeoutMs | 8000 | | useRecommendationV2ForShops | [] |

channel — controls the tracking query parameter on every request (dyn_web or dyn_cart) and switches channel-aware IF logic for subscriptions and variant titles.

storefrontCollectionsPageSize / storefrontVariantsPageSize — control first: arguments in the Storefront GraphQL query. Collections are only fetched for feeds that define collection exclusions.

sourceCacheMaxEntries — caps each internal source cache map at this many entries (default 50). When exceeded, the oldest entry is evicted (causes a refetch; never serves stale data).

useRecommendationV2ForShops — if runtime.shopDomain is in this array, the engine uses recommendationApiUrlV2 instead of recommendationApiUrl. Both default to https://recov2.dynamatics.app/v2.

vehicleCodes — pre-resolved vehicle codes used to seed metafield_recommendation returns. When non-empty, they are written into context.vehicleCodes after context normalization and sent to the Dynamatic API as dynamatic-metafield-recommendation. If vehicleCodes is empty, the metafield_recommendation source falls back to Shopify recommendations instead of calling the Dynamatic API.

metadata — arbitrary key/value data included verbatim in every error report sent by the engine. Use this to pass context like widget id, campaign id, variation id, and zone id so errors can be traced back to the exact widget configuration:

metadata: {
  widget_id: 6595,
  experience_id: 4776,
  variation_id: 4814,
  audience_id: 1474,
  campaign_id: 2339,
  zone_id: 3260,
  widget_template: 61,
}

Security notestorefrontAccessToken must be a public Storefront API token. Do not pass Admin API secrets into browser runtime code.

Config

interface RulesFeedEngineConfig {
  dedupeKey?: 'product_id';
  variantPolicy?: 'selected-return-variants';
  stockPolicy?: 'engine-agnostic';
  currentProductPolicy?: 'only-when-returned';
  includeDiagnostics?: boolean;   // default false
  includeRawProducts?: boolean;   // default true
}

Effective behavior:

  • Products are globally deduped by product_id. First occurrence wins.
  • specific-product fetches restrict assembled variants to the variant ids listed in the return value.
  • Stock levels are not filtered engine-side.
  • currentPageProduct is only returned when a rule explicitly includes a currentPageProduct return.
  • Diagnostics are only included in the result when includeDiagnostics: true.
  • includeRawProducts: false removes the product.raw field from fetched products, roughly halving per-product memory usage in caches and results.

Core Concepts

NormalizedContext

normalizeContext(context, channel) transforms the raw RulesFeedContext into a NormalizedContext that every downstream stage reads from:

interface NormalizedContext {
  channel: 'webpage' | 'cart';
  inputProducts: RulesFeedProduct[];
  currentPageProduct?: RulesFeedProduct | null;
  selectedVariantId?: string | number;
  lineItems: RulesFeedCartItem[];
  cart: RulesFeedCart;
  customer: RulesFeedCustomer;
  geo: RulesFeedGeo;
  url: string;
  orderTags: string[];
  now: Date;
  vehicleCodes: string[];

  // Precomputed for O(1) logic evaluation
  productIds: number[];
  productIdSet: Set<number>;
  productTitles: string[];
  variantTitles: string[];
  vendors: string[];
  productTypes: string[];
  productHandles: string[];
  cartSubtotal: number;
  cartLineCount: number;
  cartItemCount: number;
  logicResultCache: Map<string, boolean>;
  seedKeyCache: Map<string, string>;
}

Logic results are memoized in logicResultCache per name|type|stableStringify(value). The seed key for recommendation calls is cached in seedKeyCache.

RulesFeedRuntime

RulesFeedRuntime is the connection layer between the engine and the Shopify/Dynamatic infrastructure. It carries shop credentials, API URLs, timeout settings, and tracing metadata. It is validated and defaulted by validateInput before any pipeline stage runs.

Rules Evaluation Pipeline

The function signature of the main entry point:

function runRulesFeedEngine(input: RulesFeedEngineInput): Promise<RulesFeedEngineResult>

The engine deduplicates concurrent calls: if a second call arrives with the same channel + feedId key while a run is in progress, the second caller receives the same Promise. The in-flight map entry is cleared once the promise settles.


IF Logic Evaluation

logics is a two-level array:

logics: [
  [logicA, logicB],  // group 0 — inner items are OR
  [logicC],          // group 1 — OR
  // outer groups are AND
]

A rule passes when every group passes. A group passes when at least one logic inside it is true.

rule.logics.every((group) => group.some((logic) => evaluateLogic(logic, context)))

Rules with empty logics arrays never pass. Logic results are memoized per name + type + value within one engine run.

Logic types

Array comparison — used for tags, collections, customer tags, order tags, product ids:

| logic.type | Returns true when | |---|---| | equals-anything | Target array has at least one non-empty value | | contains | At least one logic value exists in the target array | | contains-all / contain-all | Every logic value exists in the target array | | does-not-contain | Every logic value is absent from the target array | | equals | Target and logic arrays contain exactly the same values (order-independent) | | does-not-equal / does-not-equals | Arrays are not exactly equal |

Text comparison — used for product title, vendor, type, handle, variant title, country, URL:

| logic.type | Returns true when | |---|---| | equals-anything | Any source value is non-empty | | contains | Source text includes expected text | | does-not-contain | Source text does not include expected text | | equals | Source text exactly equals expected text | | does-not-equal / does-not-equals | Source text does not equal expected text |

All text comparisons are case-insensitive.

Numeric comparison — used for inventory, cart subtotal, cart counts, customer order count, total spent:

| logic.type | Returns true when | |---|---| | is-less-than | Actual < expected | | is-greater-than | Actual > expected | | is-equal-to | Actual = expected | | is-not-equal-to | Actual ≠ expected |

Logic name reference

| Logic name | Matcher type | Source | Notes | |---|---|---|---| | products | Array / special | context.inputProducts product ids | Also handles have-subscription and doesnot-have-subscription type values | | any-product-tags | Array | Tags from any input product | | | each-product-tags | Array | Tags from every input product | | | any-product-title | Text | Title of any input product | | | each-product-title | Text | Title of every input product | | | any-product-vendor | Text | Vendor of any input product | | | each-product-vendor | Text | Vendor of every input product | | | any-product-type | Text | Type of any input product | | | each-product-type | Text | Type of every input product | | | any-product-handle | Text | Handle of any input product | | | each-product-handle | Text | Handle of every input product | | | any-product-collection | Array | Collections of any input product | | | each-product-collection | Array | Collections of every input product | | | any-product-meta | Boolean/namespace.key | Metafield on any input product | Pre-fetch required; use getRequiredProductDetails | | each-product-meta | Boolean/namespace.key | Metafield on every input product | Pre-fetch required; use getRequiredProductDetails | | any-cart-inventory | Numeric | totalInventory of any input product | | | each-cart-inventory | Numeric | totalInventory of every input product | | | any-selected-variant-title | Text | Cart: variant_title on all line items. Webpage: title of variant matching selectedVariantId, falls back to all variant titles. | Channel-aware | | each-selected-variant-title | Text | Cart: every line item variant title. Webpage: every title from selected variant(s). | Channel-aware | | customer-logged-in | — | Truthy customer.id. Pass value: 'false' to match logged-out customers. | | | customer-tag | Array | customer.tags or customer.customerTags | | | customer-order-count | Numeric | customer.ordersCount ?? customer.totalOrder ?? 0 | | | customer-total-spent | Numeric | customer.totalSpent ?? 0 | | | cart-subtotal | Numeric | (cart.totalPrice ?? cart.total_price) / 100 | | | cart-line-count | Numeric | Number of distinct line items (from cart.items or lineItems) | | | cart-item-count | Numeric | cart.item_count or summed line item quantities | | | country-name | Text | geo.countryName | | | country-code | Text | geo.countryCode | | | url | Text | context.url | | | order-tags | Array | context.orderTags | |

Note on products logic with subscription types: when logic.name === 'products', the logic.type values have-subscription and doesnot-have-subscription are handled as special cases: on the cart channel they inspect selling_plan_allocation on line items; on the webpage channel they inspect selling_plan_groups on input products.


Return Sources

Local sources (no Dynamatic API)

| Return name | Network call | Behavior | |---|---|---| | currentPageProduct | None | Returns [context.currentPageProduct] or [] | | specific-product | Shopify Storefront GraphQL | Fetches by handle; all handles in a run share one GraphQL query | | shopify-recommendation | Shopify recommendations endpoint | Seeds from the resolved recommendation seed product |

API sources (Dynamatic recommendations API)

| Return name | Notes | |---|---| | dynamatic-recommendation | General recommendation return | | products-with-tags | Also normalized from specific-tag-products | | specific-collection-products | Collection-scoped products | | metafield_recommendation | Vehicle/metafield-based recommendation. When vehicleCodes is empty, skips the Dynamatic API entirely and falls back to Shopify recommendations. When the API returns 0 products, also falls back to Shopify recommendations. | | product-price | Fallback product source; only used when no higher-priority product return exists on a rule |

All API-backed returns from matched rules within the same feed are grouped into one Dynamatic API request per feed.

metafield_recommendation and vehicleCodes

runtime.vehicleCodes drives metafield_recommendation behaviour. After context normalization, runRulesFeedEngine copies runtime.vehicleCodes into context.vehicleCodes when it is non-empty. The fetching layer then:

  1. If vehicleCodes is empty and every return in the batch is metafield_recommendation: skip the Dynamatic API and go straight to Shopify recommendations.
  2. Otherwise: call the Dynamatic API and include "dynamatic-metafield-recommendation": vehicleCodes in the top-level payload.
  3. If the API call succeeds but returns 0 products for metafield_recommendation: fall back to Shopify recommendations for that slot.

The fallback is logged in debug mode:

[FETCH] [metafield_recommendation] API returned 0 products — falling back to Shopify recommendations

Storefront API details (specific-product)

Endpoint: https://{shopDomain}/api/2025-01/graphql.json?{dyn_web|dyn_cart}

Headers:

content-type: application/json
x-shopify-storefront-access-token: {runtime.storefrontAccessToken}

Requires: runtime.shopDomain, runtime.storefrontAccessToken

Fetched fields per product: id, handle, title, vendor, productType, tags, totalInventory, priceRange.minVariantPrice.amount, variants(first: 249), collections(first: 250) — collections only fetched when the feed defines collection exclusions.

Shopify GID prefixes (gid://shopify/Product/, gid://shopify/ProductVariant/, gid://shopify/Collection/) are stripped from all returned ids.

Shopify recommendations details (shopify-recommendation)

Endpoint: {shopifyRoutesRoot}recommendations/products.json?product_id={id}&intent={intent}&{dyn_web|dyn_cart}

intent comes from return.value, defaulting to 'related'.

Requires a resolvable recommendation seed product. Fails gracefully with a diagnostics entry when no seed product exists.


Batch API Call Behavior

All matched rules from the same feed that have API-backed returns are collected into a single network request. N matched rules sharing a feed produce one Dynamatic API call.

The batch cache key format is:

api:feed:<feedId>:<fnv1a-hash(rules + seed + vehicleCodes)>

Where:

  • rules is the array of { ruleId, returns } entries for all matched rules in the feed
  • seed is the resolved recommendation seed key (encodes cart contents or current page product id)
  • vehicleCodes is context.vehicleCodes

The SourceFetchRequest carries batchRules: BatchRuleEntry[]:

interface BatchRuleEntry {
  ruleId: string | number;
  returns: RulesFeedReturn[];
  rule: RulesFeedRule;
}

Dynamatic API request payload

{
  "lineitems": [
    { "product_id": "123", "variants": [{ "variant_id": "456" }] }
  ],
  "cartData": {
    "currency": "USD",
    "items_count": 2,
    "total_price": 2598
  },
  "ruleData": {
    "rules": [
      {
        "rule_id": "rule-id-1",
        "return_ordering": "shuffle",
        "returns": [
          { "name": "dynamatic-recommendation", "type": null, "value": "surprise", "mode": "include" }
        ]
      }
    ],
    "exclusions": [
      { "filterType": "tags", "tags": ["hidden"] }
    ]
  },
  "dynamatic-metafield-recommendation": ["CODE123"]
}

Payload notes:

  • ruleData.rules contains all matched rules from the feed.
  • ruleData.exclusions is a sibling of rules inside ruleData, not nested inside each rule.
  • return_ordering comes from rule.returnOrdering; defaults to 'shuffle' when absent.
  • mode on each return defaults to 'include' when absent on old feeds.
  • filters on each return is forwarded as-is; omitted from the JSON when there are no filters.
  • dynamatic-metafield-recommendation is added to the top-level payload only when context.vehicleCodes is non-empty.

API endpoint selection

POST {baseUrl}/api/recommendations?{dyn_web|dyn_cart}

Headers:

content-type: application/json
x-public-key: {runtime.recommendationPublicKey}
x-shop-domain: {runtime.shopDomain}

If runtime.shopDomain is in runtime.useRecommendationV2ForShops, baseUrl is runtime.recommendationApiUrlV2; otherwise runtime.recommendationApiUrl. Both default to https://recov2.dynamatics.app/v2.


Source Caching and Deduplication

The optional cache object is a RulesFeedSourceCache:

interface RulesFeedSourceCache {
  shopifyProductHandles?: Map<string, RulesFeedProduct[]>;
  shopifyRecommendations?: Map<string, RulesFeedProduct[]>;
  dynamaticRecommendations?: Map<string, Record<string, RulesFeedProduct[]>>;
  pendingShopifyProductHandles?: Map<string, Promise<RulesFeedProduct[]>>;
  pendingShopifyRecommendations?: Map<string, Promise<RulesFeedProduct[]>>;
  pendingDynamaticRecommendations?: Map<string, Promise<Record<string, RulesFeedProduct[]>>>;
}

Pass an empty object {} and the engine initializes all six maps automatically.

Bounded eviction — each result cache map is bounded by runtime.sourceCacheMaxEntries (default 50). When the cap is hit, the oldest entry (insertion order) is evicted. Eviction costs a refetch — it never serves stale data. Cache keys embed the cart hash and channel so long sessions with changing carts do not collide.

In-flight deduplication (source level) — the three pending maps hold in-flight promises. When two simultaneous engine runs request the same source key, the second run waits for the first run's promise instead of starting a duplicate request. The pending entry is deleted once the promise settles.

In-flight deduplication (engine level)runRulesFeedEngine deduplicates at the call level. If a second call arrives for the same channel + feedId while a run is in progress, the second caller gets the same Promise. Once the promise settles, the in-flight entry is removed so future calls start fresh.

usedApiKeys — a Set<string> local to each assembleProducts call. Prevents batch API products from being added more than once when multiple rules in the same feed share the same batch cache key. The first rule adds the bucket; later rules with the same key skip it.

Cache key format: {shopDomain.toLowerCase()}:{dyn_web|dyn_cart}:{sourceKey}[:noraw]


Product Assembly

After fetching, assembleProducts builds the final product list from plannedRules in order:

  1. Apply customization metadata (title, subtitle, hide-widget) to the shared customization object. Later rules overwrite earlier values.
  2. Find product source returns. product-price is only used when no other product return exists on the rule.
  3. For each API-backed return: if the rule's apiSourceKey has not been used yet, add the full API product bucket and mark it used. Skip the bucket on all later rules sharing the same key.
  4. For each local return (specific-product, shopify-recommendation, currentPageProduct):
    • specific-product — filters assembled variants to the variant ids listed in return.value.
    • shopify-recommendation — applies exclude-specific-product return exclusions engine-side.
    • All local sources — apply compiled feed exclusions and filter_input_product via applyCompiledProductFilters.
  5. Collect products from all buckets in order, stopping at the product limit. Default is 10; overridden by a product-limit return.
  6. Attach ruleId, feedId, discount, and productDiscount to each product.
  7. Append to the global product list.

After all rules: dedupeProducts removes duplicates by normalized product_id. First occurrence wins; products without a usable id are dropped.


Exclusion Handling

Feed-level exclusions are applied differently depending on the return source type:

| Source type | Where exclusions are applied | |---|---| | API-backed returns (dynamatic-recommendation, products-with-tags, specific-collection-products, metafield_recommendation, product-price) | Sent to the Dynamatic backend inside ruleData.exclusions. Not applied engine-side. | | Local returns (specific-product, shopify-recommendation, currentPageProduct) | Applied engine-side via applyCompiledProductFilters. |

Exclusion shape:

interface RulesFeedExclusion {
  filterType?: 'products' | 'collections' | 'tags' | string;
  products?: Array<{ product_id: string | number; product_handle: string }>;
  collections?: RulesFeedCollection[];
  tags?: string[];
}

| filterType | Engine-side behavior (local sources) | |---|---| | 'products' | Excludes products by normalized product_id | | 'collections' | Excludes products whose collections match any excluded collection handle or id | | 'tags' | Excludes products with any tag matching an excluded tag (case-insensitive) | | Other / missing | No effect |

Collection exclusions also affect the Storefront GraphQL query: collections is only fetched for feeds that define collection exclusions. The request key includes :nc for feeds without them to keep the slimmer cached result separate.

filter_input_product: true additionally excludes, from local sources, any product whose id or handle appears in a products IF logic node within the feed's rules.


Output Shape

RulesFeedEngineResult

interface RulesFeedEngineResult {
  isActive: boolean;                   // true when products.length > 0
  products: RulesFeedProduct[];        // deduped, with ruleId/feedId/discount attached
  customization: {
    title?: string | null;
    subtitle?: string | null;
    hideWidget?: boolean;
  };
  passedRules: PassedRule[];
  diagnostics?: RulesFeedDiagnostics;  // only when config.includeDiagnostics is true
}

Cart adapter output (toCartRulesFeedOutput)

interface CartRulesFeedOutput {
  isActive: boolean;
  products: Array<{
    product_handle?: string;
    product_variants: Array<{ variant_id: string | number }>;
    ruleId?: string | number;
    feedId?: string | number;
    productDiscount?: string;
  }>;
  title?: string | null;
  subtitle?: string | null;
  hideWidget?: boolean;
}

title, subtitle, and hideWidget are only included when truthy.


Backward Compatibility

All fields added after the initial release have safe defaults. Old feeds require no migration:

| Missing field | Behavior | |---|---| | rule.returnOrdering | 'shuffle' sent to Dynamatic API | | return.mode | 'include' sent to Dynamatic API | | return.filters | Field omitted from JSON payload | | feed.exclusions | exclusions: [] sent in ruleData | | config.includeRawProducts | true (raw API payload attached to each product) | | runtime.metadata | Omitted from error reports | | runtime.vehicleCodes | []dynamatic-metafield-recommendation not sent to the API |


Error Reporting

The engine automatically reports API failures to the Dynamatic error log endpoint (https://dev.dynamatics.app/api/dynamatic/cart/error-log). No extra error handling code is required.

What gets reported

An error report is sent when a network request fails and is not a timeout or a cancelled request (those are expected during navigation). This covers:

  • HTTP error responses (4xx, 5xx)
  • Network or connectivity failures
  • Empty responses from the Dynamatic API (flat array with zero items)

Shopify-side errors (4xx responses and JSON parse failures from the Storefront API) are detected and not forwarded to the error reporter, since these indicate configuration issues rather than infrastructure failures.

Rate limiting

Reports are rate-limited to one report per error class per source per shop every 5 minutes. Cooldown timestamps are persisted in localStorage under the key rfe_err_cooldowns. Error classes:

| Class | Covers | |---|---| | 5xx | Server errors (http-500, http-502, etc.) | | 4xx | Client errors (http-400, http-422, etc.) | | network | Network failures, CORS blocks |

What each report contains

{
  "shop_url": "example.myshopify.com",
  "errors": {
    "errorType": "Rules Feed Engine API Error",
    "timestamp": "2026-06-24T10:00:00.000Z",
    "channel": "cart",
    "source": "dynamatic-recommendation",
    "status": 502,
    "reason": "http-502: Bad Gateway",
    "url": "https://recov2.dynamatics.app/v2/api/recommendations",
    "feedId": 509,
    "ruleId": "708f93c7-...",
    "ruleLabel": "Rule 1 (708f93c7-...)",
    "metadata": {
      "widget_id": 6595,
      "campaign_id": 2339
    },
    "requestBody": { "lineitems": [], "cartData": { "..." : "..." }, "ruleData": { "...": "..." } },
    "responseBody": "Bad Gateway",
    "curl": "curl -X POST 'https://recov2.dynamatics.app/v2/api/recommendations?dyn_cart' -H 'content-type: application/json' -H 'x-public-key: ...' -H 'x-shop-domain: example.myshopify.com' -d '{...}'",
    "shopify": { "shop": "example.myshopify.com" },
    "page": { "url": "https://...", "pathname": "/cart" }
  }
}

Key fields:

| Field | Description | |---|---| | status | HTTP status code (e.g. 502). Absent for network-level failures. | | reason | Human-readable failure reason (e.g. http-502: Bad Gateway). | | responseBody | Raw response text from the server, up to 1000 characters. | | requestBody | The exact JSON body (or query params for GET) that was sent when the error occurred. | | curl | A ready-to-run single-line curl command to reproduce the failing request. | | metadata | The runtime.metadata object passed by the channel. |

Passing metadata from your channel

// Webpage channel
runtime: {
  channel: 'webpage',
  metadata: {
    widget_id: property?.id,
    experience_id: property?.experienceId,
    variation_id: property?.variationId,
    audience_id: property?.audienceId,
    campaign_id: property?.campaignId,
    zone_id: property?.zoneId,
    widget_template: property?.templateId,
  },
}

// Cart channel
runtime: {
  channel: 'cart',
  metadata: {
    widget_id: widget?.id,
    experience_id: widget?.experience_id,
    variation_id: widget?.variation_id,
    campaign_id: widget?.campaign_id,
    zone_id: widget?.zone_id,
  },
}

Diagnostics

Enable with config: { includeDiagnostics: true }.

interface RulesFeedDiagnostics {
  matchedRules: Array<{ feedId?: string | number; ruleId?: string | number; ruleName?: string }>;
  skippedRules: Array<{ feedId?: string | number; ruleId?: string | number; reason: string }>;
  fetchPlan: Array<{ source: string; key: string; deduped: boolean }>;
  failedSources: Array<{ source: string; key: string; reason: string; timedOut?: boolean; aborted?: boolean }>;
  timings: Record<string, number>;
  fallbacks: Array<{ source: string; fallback: string; reason: string }>;
  validationWarnings: string[];
}
  • matchedRules — every rule that passed IF evaluation, in evaluation order.
  • skippedRules — rules skipped before evaluation, with reason (missing-rule-id, invalid-logic-type).
  • fetchPlan — every source request that was planned. Entries after the first with the same batch key have deduped: true.
  • failedSources — network errors, timeouts, aborts, or missing configuration. Failures are recorded here; they do not throw.
  • timings — millisecond durations keyed by fetch:{sourceKey} and total.
  • validationWarnings — emitted when shopDomain or storefrontAccessToken are missing.

Diagnostics do not include raw cart contents, customer data, or API tokens.


Debug Logging

The engine emits detailed, color-coded browser console logs for every stage of the pipeline. Logs are gated behind localStorage.getItem('DYN_RULES_FEED_ENGINE_DEBUG') === 'true'.

Enabling debug logs

Open your browser's DevTools console and run:

localStorage.setItem('DYN_RULES_FEED_ENGINE_DEBUG', 'true')

Then trigger the widget (navigate, reload, or add to cart). To turn off:

localStorage.removeItem('DYN_RULES_FEED_ENGINE_DEBUG')

Always-on summary lines

Four summary lines always appear regardless of the debug flag. The prefix reflects the channel:

[DYNAMATIC WEBPAGE RULES FEED]  — 10 products · 1 rules passed
[DYNAMATIC WEBPAGE RULES FEED]  Final Products   ▶ Array(10)
[DYNAMATIC WEBPAGE RULES FEED]  Passed Rules     ▶ Array(1)
[DYNAMATIC WEBPAGE RULES FEED]  Customization    ▶ Object {}

Badge color reference

| Badge color | Label | Meaning | |---|---|---| | Cyan [ENGINE] | [ENGINE] | Pipeline milestones (validation, dedupe, timing) | | Purple [RULE] | [RULE] | Rule being evaluated | | Green [PASS] | [PASS] | Logic group passed / rule matched / products assembled | | Red [SKIP] | [SKIP] | Logic group failed / rule skipped / invalid logic type | | Amber [LOGIC] | [LOGIC] | Per-logic debug values | | Sky [FETCH] | [FETCH] | Fetch plan, network requests, and cache events | | Amber [WARN] | [WARN] | Non-fatal issue (null value, missing config, deduped products) |

Channel prefixes: [DYNAMATIC WEBPAGE RULES FEED] (purple) or [DYNAMATIC CART RULES FEED] (hot pink).


Key Types and Interfaces

All types are defined in src/types.ts and re-exported from the package root.

Input types: RulesFeedEngineInput, RulesFeedFeed, RulesFeedRule, RulesFeedReturn, RulesFeedReturnFilter, RulesFeedExclusion, RulesFeedContext, RulesFeedRuntime, RulesFeedEngineConfig, RulesFeedSourceCache

Product model: RulesFeedProduct, RulesFeedVariant, RulesFeedCollection, RulesFeedMetafield, RulesFeedCartItem, RulesFeedCart, RulesFeedCustomer, RulesFeedGeo

Pipeline internals: NormalizedContext, PassedRule, PlannedRuleReturn, ReturnPlan, EvaluationResult, SourceFetchRequest, SourceFetchResult, BatchRuleEntry, RuleReturnMeta

Output types: RulesFeedEngineResult, CartRulesFeedOutput, RulesFeedDiagnostics, RulesFeedCustomization

Union types: ReturnSourceKey, ReturnOrdering, RecommendationSeed


Public API Reference

All exports are from @abuhasanrumi/rules-feed-engine.

Functions

| Export | Signature | Description | |---|---|---| | runRulesFeedEngine | (input: RulesFeedEngineInput) => Promise<RulesFeedEngineResult> | Main entry point. Runs the full pipeline. | | toCartRulesFeedOutput | (result: RulesFeedEngineResult) => CartRulesFeedOutput | Converts engine result to cart output shape. | | evaluateRules | (feeds, context, baseDiagnostics?) => EvaluationResult | IF logic stage only. | | planReturns | (passedRules, context, diagnostics, runtime?) => ReturnPlan | Return planning and batch grouping stage. | | fetchSources | (requests, context, runtime, cache, diagnostics, signal?, config?) => Promise<SourceFetchResult[]> | Source fetching stage. | | normalizeContext | (context, channel) => NormalizedContext | Context normalization. | | recommendationSeedProductId | (context: NormalizedContext) => string | Returns the product id used for seeding recommendations. | | validateInput | (input: RulesFeedEngineInput) => ValidatedInput | Applies defaults, normalizes feeds, initializes caches. | | defaultConfig | (config?: RulesFeedEngineConfig) => Required<RulesFeedEngineConfig> | Returns config with all defaults filled in. | | applyExclusions | (products, exclusions) => RulesFeedProduct[] | Applies an exclusion array to a product list. | | dedupeProducts | (products: RulesFeedProduct[]) => RulesFeedProduct[] | Global dedupe by product id. | | feedHasCollectionExclusions | (feed?: RulesFeedFeed) => boolean | Returns true when the feed defines at least one collection exclusion. | | filterInputProducts | (products, rules, enabled) => RulesFeedProduct[] | Applies filter_input_product logic to a list. | | getRequiredProductDetails | (feeds: RulesFeedFeed[]) => { requiresProductInfo: boolean; metafieldNamespace?: string; metafieldKey?: string } | Detects whether rules need pre-fetched product details. | | evaluateLogic | (logic: RulesFeedLogic \| null \| undefined, context) => boolean | Evaluates a single logic node. Returns false for null or nameless logic. | | checkArrayMatchCondition | (actual, expected, type) => boolean | Array comparison primitive. | | checkNumericCondition | (actual, expected, type) => boolean | Numeric comparison primitive. | | checkTextMatchCondition | (actual, expected, type) => boolean | Text comparison primitive. | | selectedVariantIdsFromReturnValue | (value) => Set<string> | Extracts variant ids from a return value. | | buildRuleReturnMeta | (returns) => RuleReturnMeta | Splits returns into product returns and metadata returns. | | isApiReturn | (ret: RulesFeedReturn) => boolean | Returns true for API-backed return names. | | isProductReturn | (ret: RulesFeedReturn) => boolean | Returns true for product-producing return names. | | asSourceKey | (name?: string) => ReturnSourceKey \| null | Maps a normalized return name to its ReturnSourceKey. | | normalizeReturnKey | (name?: string) => string | Applies return name normalization aliases. | | specificProductKey | (ret: RulesFeedReturn) => string | Derives the cache key for a specific-product return. | | specificProductRequestKey | (ret, feed?) => string | Derives the request key (includes :nc when feed has no collection exclusions). | | shopifyRecommendationKey | (ret, context, runtime?, feed?) => string | Derives the cache key for a shopify-recommendation return. | | currentPageKey | (context: NormalizedContext) => string | Derives the cache key for a currentPageProduct return. |

Constants

| Export | Type | Description | |---|---|---| | METADATA_RETURN_NAMES | Set<string> | The set of recognized metadata return names (product-limit, title, subtitle, hide-widget, product-discount, exclude-specific-product, shuffle). | | PRODUCT_INFO_LOGIC_NAMES | Set<string> | Logic names that require pre-fetched product details (products, any-product-tags, each-product-tags, any-product-collection, each-product-collection, any-cart-inventory, each-cart-inventory, any-product-meta, each-product-meta). |


End-to-End Example

Two rules in one feed, both with API-backed returns, producing one batched Dynamatic API call.

import { runRulesFeedEngine, toCartRulesFeedOutput } from '@abuhasanrumi/rules-feed-engine';

const result = await runRulesFeedEngine({
  runtime: {
    channel: 'webpage',
    shopDomain: 'example.myshopify.com',
    storefrontAccessToken: 'public-storefront-token',
    shopifyRoutesRoot: '/',
    requestTimeoutMs: 8000,
    metadata: {
      widget_id: 6595,
      experience_id: 4776,
      variation_id: 4814,
      campaign_id: 2339,
    },
  },
  config: { includeDiagnostics: true },
  context: {
    inputProducts: [
      {
        product_id: 1,
        product_handle: 'trigger-product',
        product_title: 'Trigger Product',
        tags: ['Starter'],
        collections: [{ id: 111, handle: 'frontpage', title: 'Frontpage' }],
        product_variants: [{ variant_id: 11, variant_title: 'Default' }],
        totalInventory: 5,
      },
    ],
    currentPageProduct: {
      product_id: 1,
      product_handle: 'trigger-product',
      product_variants: [{ variant_id: 11 }],
    },
    cart: { total_price: 2500, item_count: 1, currency: 'USD' },
    customer: {},
    geo: {},
    url: 'https://example.myshopify.com/products/trigger-product',
  },
  cache: {},
  feeds: [
    {
      id: 'feed-1',
      rules: [],
      exclusions: [{ filterType: 'tags', tags: ['hidden'] }],
      filter_input_product: true,
      recommendation_seed: 'currentPageProduct',
      rules: [
        {
          id: 'rule-a',
          name: 'Starter tag — general recommendations',
          returnOrdering: 'shuffle',
          logics: [
            [{ name: 'any-product-tags', type: 'contains', value: ['Starter'] }],
          ],
          returns: [
            { name: 'dynamatic-recommendation', type: null, value: 'surprise', mode: 'include' },
            { name: 'product-discount', type: 'percentage', value: 10 },
            { name: 'title', type: null, value: 'You may also like' },
            { name: 'product-limit', type: null, value: 5 },
          ],
        },
        {
          id: 'rule-b',
          name: 'Starter tag — specific collection',
          isMatched: true,
          logics: [
            [{ name: 'any-product-tags', type: 'contains', value: ['Starter'] }],
          ],
          returns: [
            {
              name: 'specific-collection-products',
              type: 'specific-collection',
              mode: 'include',
              value: [{ id: '123', handle: 'sale' }],
              filters: [
                { id: 'f1', name: 'product-price', type: 'less-than', value: '200' },
              ],
            },
            { name: 'product-limit', type: null, value: 5 },
          ],
        },
      ],
    },
  ],
});

// Webpage: products array with ruleId, feedId, discount attached
console.log(result.products);
console.log(result.customization); // { title: 'You may also like' }

// Cart: compact handle + variant id shape
const cart = toCartRulesFeedOutput(result);

What happens internally:

  1. Both rules pass because the input product has tag Starter.
  2. rule-b has isMatched: true — no further rules in this feed are evaluated after it passes.
  3. Both rules' API-backed returns are grouped into one SourceFetchRequest with batchRules containing both rule entries.
  4. One POST is sent to https://recov2.dynamatics.app/v2/api/recommendations?dyn_web with ruleData.rules carrying both rules and ruleData.exclusions: [{ filterType: 'tags', tags: ['hidden'] }].
  5. Assembly adds batch API products on first encounter (rule-a). rule-b's bucket is skipped by the usedApiKeys guard. Product limit of 5 applies per rule.
  6. Discount '10%' is attached to products from rule-a.
  7. filter_input_product: true means the trigger product (id 1) is excluded from any local return results in this feed.
  8. Final dedupeProducts removes any duplicates by product_id.
  9. If any API call fails during fetching, an error report is automatically sent to the Dynamatic error log with the full request body, response, and a ready-to-run curl command.

Development

Requirements: Bun

bun install
bun run build      # removes dist/ with node and compiles TypeScript via tsc
bun run test       # runs bun run build first, then executes test/*.test.mjs
bun run typecheck  # type-checks without emitting output (tsc --noEmit)

The build script is bun run clean && tsc -p tsconfig.json. The clean script removes dist/ using node -e "require('fs').rmSync(...)". No Bun-specific build APIs are used — the compiler is standard tsc.

Publishing:

bun run build
npm publish --access public

The files field in package.json restricts the published package to dist/ and README.md.


Version History

Based on recent git log:

| Version | Commit | Summary | |---|---|---| | 1.1.5 | 2eb9e19 | Version bump | | 1.1.4 | 9d60427 | Fix: metafield-recommendation was always sent in published 1.1.3 dist | | 1.1.3 | 8d3df68 | Version bump | | 1.1.2 | e912d15 | Version bump | | 1.1.1 | d3f14cf | Enhanced error reporting with Shopify error detection | | 1.1.0 | a8aa4b6 | Version bump | | — | 5b6b0d0 | Fix: updated vehicleCodes condition and removed vehicleCodesEnabled flag from RulesFeedRuntime | | — | 235e2be | Feat: enhanced vehicle code handling for dynamatic-metafield-recommendation; added vehicleCodesEnabled flag (later removed) | | — | 64d59b4 | Simplified payload assignment for dynamatic-metafield-recommendation; added fallback for missing vehicle codes | | — | 32cc1e2 | Updated README to clarify engine capabilities and error reporting details |