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

@commerce-blocks/sdk

v2.0.0

Published

ES module SDK for product discovery — browse collections, search, recommendations, and image search via Layers API.

Readme

@commerce-blocks/sdk

ES module SDK for product discovery — browse collections, search, recommendations, and image search via Layers API.

Installation

npm install @commerce-blocks/sdk

Quick Start

import { createClient } from '@commerce-blocks/sdk'

const { data: client, error } = createClient({
  token: 'your-layers-token',
  sorts: [
    { name: 'Featured', code: 'featured' },
    { name: 'Price: Low to High', code: 'price_asc' },
  ],
  facets: [
    { name: 'Color', code: 'options.color' },
    { name: 'Size', code: 'options.size' },
  ],
})

if (error) throw new Error(error.message)

const collection = client.collection({ handle: 'shirts' })
const result = await collection.execute()

if (result.data) {
  console.log(result.data.products)
  console.log(result.data.totalResults)
}

collection.dispose()

Configuration

| Option | Type | Required | Description | | --------------- | ------------------------------ | -------- | ------------------------------------ | | token | string | Yes | Layers API public token | | sorts | Sort[] | Yes | Sort options { name, code } | | facets | Facet[] | Yes | Facet fields { name, code } | | attributes | string[] | No | Product attributes to fetch | | baseUrl | string | No | Custom API URL | | fetch | CustomFetch | No | Custom fetch (SSR, testing) | | currency | string | No | Currency for price formatting | | formatPrice | (amount, currency) => string | No | Custom price formatter | | swatches | Swatch[] | No | Color swatch definitions | | transforms | Transforms | No | Post-process results (see below) | | filterAliases | FilterAliases | No | URL-friendly filter key mapping | | cacheLimit | number | No | Max entries in cache | | cacheLifetime | number | No | TTL in milliseconds | | storage | StorageAdapter | No | Custom storage adapter | | initialData | CacheData | No | Pre-populate cache at init | | restoreCache | boolean | No | Restore from storage (default: true) |

Controllers

All controllers (collection, blocks, search, suggest) follow the same pattern:

  • state — a ReadonlySignal<QueryState<T>> with { data, error, isFetching }
  • execute() — runs the query, returns Result<T, ClientError>
  • subscribe(callback) — reacts to state changes without importing signals
  • dispose() — cleans up subscriptions and aborts pending requests

Subscribing to State

Three ways to consume controller state (pick one):

// 1. Controller subscribe — no signal import needed
const unsubscribe = controller.subscribe(({ data, error, isFetching }) => {
  if (isFetching) showLoading()
  if (error) showError(error.message)
  if (data) render(data.products)
})

// 2. Standalone subscribe — works with any signal
import { subscribe } from '@commerce-blocks/sdk'
const unsubscribe = subscribe(controller.state, (state) => {
  /* ... */
})

// 3. Direct signal access — for custom reactivity
import { effect } from '@commerce-blocks/sdk'
effect(() => {
  const { data } = controller.state.value
})

Shared Query Parameters

These parameters are available on execute() for all controllers except suggest:

| Parameter | Type | Description | | ------------------ | ------------------------- | ---------------------------------- | | page | number | Page number (default: 1) | | limit | number | Products per page (default: 24) | | filters | unknown | Filter criteria | | signal | AbortSignal | Per-call abort signal | | linking | Record<string, unknown> | Dynamic linking parameters | | transformRequest | (body) => body | Custom request body transformation |

Collection

Browse products in a collection.

const collection = client.collection({
  handle: 'shirts',
  defaultSort: 'featured', // optional, uses first sort if omitted
})

await collection.execute() // initial load
await collection.execute({ page: 2 }) // paginate
await collection.execute({ sort: 'price_asc' }) // change sort
await collection.execute({ filters: { color: 'Red' } })
await collection.execute({ includeMeta: true }) // fetch collection metadata

collection.dispose()

Additional execute() params: sort, includeMeta.

Blocks

Product recommendations powered by Layers blocks. Anchored to a product, collection, or cart.

const blocks = client.blocks({
  blockId: 'block-abc123',
  anchor: 'gold-necklace', // product/collection ID or handle
})

await blocks.execute()
await blocks.execute({
  discounts: [
    {
      entitled: { all: true },
      discount: { type: 'PERCENTAGE', value: 10 },
    },
  ],
  context: {
    productsInCart: [{ productId: '123', variantId: '456', quantity: 1 }],
    geo: { country: 'US' },
  },
})

// result.data.block has { id, title, anchor_type, strategy_type, ... }
blocks.dispose()

Additional execute() params: discounts, context.

Search

Full-text search with facets. Options persist across calls — subsequent execute() calls merge with existing options.

const search = client.search({ query: 'ring', limit: 20 })

await search.execute()
await search.execute({ page: 2 }) // page persists
await search.execute({ filters: { vendor: 'Nike' } }) // filters update

// Temporary override (doesn't persist for next call)
await search.execute({ query: 'shoes', temporary: true })

// Prepare search (caches searchId for faster execute)
await search.prepare()
await search.execute() // uses cached searchId

search.dispose()

Additional execute() params: query, searchId, tuning, temporary.

SearchTuning controls matching weights: textualWeight, visualWeight, multipleFactor, minimumMatch.

Suggest

Predictive search with debouncing and local caching. Only full words (trailing space) are cached — partial input filters cached results client-side.

const suggest = client.suggest({ debounce: 300 })

suggest.subscribe(({ data }) => {
  if (data) renderSuggestions(data.matchedQueries)
})

input.addEventListener('input', (e) => {
  suggest.execute(e.target.value) // debounced automatically
})

suggest.dispose()

| Option | Type | Description | | ------------------- | ------------- | ------------------------------------------- | | debounce | number | Debounce delay in ms (default: 300) | | excludeInputQuery | boolean | Remove user's input from suggestions | | excludeQueries | string[] | Custom strings to filter from suggestions | | signal | AbortSignal | Shared abort signal (acts like dispose()) |

Image Search

Upload an image, then search by it.

const upload = client.uploadImage({ image: file })
upload.subscribe(({ data }) => {
  if (!data) return
  const results = client.searchByImage({ imageId: data.imageId })
  results.subscribe(({ data }) => {
    if (data) console.log('Similar:', data.products)
  })
})

Abort Signals

Controllers support two levels of abort:

// Shared signal — cancels everything when component unmounts
const ac = new AbortController()
const search = client.search({ query: 'ring', signal: ac.signal })

// Per-call signal — cancels only this request
const req = new AbortController()
await search.execute({ page: 2, signal: req.signal })

// Either aborting cancels the request (they're linked internally)
ac.abort() // cancels all pending + acts like dispose()

Collection and blocks auto-cancel the previous request when a new execute() starts.

Product Card

Reactive controller for product cards with variant selection and availability logic. All derived values are computed signals that auto-update when inputs change.

import { createProductCard, effect } from '@commerce-blocks/sdk'

const card = createProductCard({
  product,
  selectedOptions: [{ name: 'Size', value: '7' }],
  breakoutOptions: [{ name: 'Stone', value: 'Ruby' }],
})

// Reactive signals
effect(() => {
  console.log('Variant:', card.selectedVariant.value)
  console.log('Options:', card.options.value) // OptionGroup[]
  console.log('Images:', card.images.value)
  console.log('Price:', card.price.value) // PriceData
})

// Actions
card.selectOption({ name: 'Size', value: 'L' })
card.setSelectedOptions([{ name: 'Size', value: 'L' }]) // merge by name
card.setSelectedVariant(12345) // select by numeric variant ID
card.setCarouselPosition(3) // 1-based manual override

card.subscribe(({ selectedVariant, options, price }) => {
  // Called on any state change
})

card.dispose()

Reactive state (all ReadonlySignal): variants, selectedVariant, options, images, price, priceRange, carouselPosition, isSelectionComplete.

Options include availability status baked in:

interface OptionGroup {
  name: string
  values: OptionValue[]
}

interface OptionValue {
  value: string
  status: 'available' | 'backorderable' | 'sold-out' | 'unavailable'
  selected: boolean
  swatch: Swatch | null
}

interface PriceData {
  price: Price | null
  compareAtPrice: Price | null
  isOnSale: boolean
}

interface PriceRangeData {
  priceRange: PriceRange
  compareAtPriceRange: PriceRange | null
}

Filters

Build filters using the DSL:

import { filter, and, or, eq, gte, lte, inValues } from '@commerce-blocks/sdk'

await collection.execute({
  filters: filter(and(eq('options.color', 'Red'), eq('options.size', 'Medium'))),
})

// Price range
filter(and(gte('price', 50), lte('price', 200)))

// Multiple values
filter(or(eq('vendor', 'Nike'), eq('vendor', 'Adidas')))

| Operator | Description | | ------------------------------ | ----------------------- | | eq(property, value) | Equals | | notEq(property, value) | Not equals | | inValues(property, values[]) | In list | | notIn(property, values[]) | Not in list | | gt(property, number) | Greater than | | gte(property, number) | Greater than or equal | | lt(property, number) | Less than | | lte(property, number) | Less than or equal | | exists(property) | Property exists | | notExists(property) | Property does not exist |

Transforms and Filter Aliases

Configure once at init — applied automatically to all results. The product transform extends every Product with custom fields via the generic Product<T> type:

import type { Product } from '@commerce-blocks/sdk'

type MyProduct = Product<{ description: string; rating: number }>

const { data: client } = createClient({
  // ...config
  attributes: ['body_html'],
  transforms: {
    product: ({ raw }) => ({
      description: raw.body_html ?? '',
      rating: raw.calculated?.average_rating ?? 0,
    }),
    collection: (result, raw) => result,
    search: (result, raw) => result,
    block: (result, raw) => result,
    filters: (filters) => filters,
  },
  filterAliases: {
    color: 'options.color',
    size: 'options.size',
    brand: { property: 'vendor', values: { nike: 'Nike', adidas: 'Adidas' } },
  },
})

// Aliases resolve automatically
await collection.execute({ filters: { color: 'Red', brand: 'nike' } })
// Products now include description and rating from the product transform

Error Handling

All methods return { data, error } instead of throwing. Errors are discriminated by _tag:

const result = await collection.execute()

if (result.error) {
  switch (result.error._tag) {
    case 'NetworkError':
      // code: 'TIMEOUT' | 'CONNECTION_FAILED' | 'DNS_FAILED' | 'SSL_ERROR' | 'ABORTED' | 'OFFLINE'
      break
    case 'ApiError':
      // code: 'NOT_FOUND' | 'RATE_LIMITED' | 'UNAUTHORIZED' | ...
      // status: HTTP status code, source: 'layers'
      break
    case 'ValidationError':
      // operation: which method failed, fields: [{ field, code, message }]
      break
    case 'ConfigError':
      // field: which config field, expected: what was expected
      break
  }
}

isRetryable classifies errors by tag, code, and status — use it standalone or as a shouldRetry predicate:

import { isRetryable } from '@commerce-blocks/sdk'

if (result.error && isRetryable(result.error)) {
  const delay = result.error.retryAfterMs ?? 1000
  setTimeout(() => collection.execute(), delay)
}

Cache and Storage

The client exposes a reactive cache:

const { cache } = client

cache.get('cache-key') // CacheEntry<QueryResult> | null
cache.invalidate('browse') // invalidate keys containing 'browse'
cache.persist() // save to storage
cache.restore() // restore from storage
cache.clear() // clear all
cache.stats.entries // current entry count

Storage Adapters

import { localStorageAdapter, fileStorage } from '@commerce-blocks/sdk'

// Browser (returns null if unavailable)
const browserAdapter = localStorageAdapter('my-cache-key')

// Node.js
import fs from 'fs'
const nodeAdapter = fileStorage('./cache.json', fs)

Custom adapter — implement StorageAdapter:

const adapter: StorageAdapter = {
  read: () => sessionStorage.getItem('key'),
  write: (data) => sessionStorage.setItem('key', data),
  remove: () => sessionStorage.removeItem('key'),
}

Signals

The SDK re-exports @preact/signals-core primitives for reactive state:

import { signal, computed, effect, batch } from '@commerce-blocks/sdk'

Controller state is a ReadonlySignal<QueryState<T>>:

interface QueryState<T> {
  data: T | null
  error: ClientError | null
  isFetching: boolean
}

All controllers return the same QueryResult shape in data:

interface QueryResult {
  products: Product[]
  totalResults: number
  totalPages: number
  page: number
  facets: Record<string, Record<string, number>>
  priceRange?: PriceRange
  attributionToken?: string
}

Blocks results add a block field with { id, title, anchor_type, strategy_type, strategy_key }.

Singleton Access

After initialization, access the client anywhere:

import { getClient, isInitialized } from '@commerce-blocks/sdk'

if (isInitialized()) {
  const { data: client } = getClient()
  // Use client
}

Response Types

The SDK exports all types from @commerce-blocks/sdk:

import type {
  Client,
  ClientConfig,
  QueryState,
  QueryResult,
  Product,
  ProductBase,
  ProductVariant,
  OptionGroup,
  OptionValue,
  PriceData,
  PriceRangeData,
  ClientError,
  NetworkError,
  ApiError,
} from '@commerce-blocks/sdk'