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

@asstio-dev/storefront

v0.11.0

Published

Typed client for the Asstio Storefront API (generated from OpenAPI).

Readme

@asstio-dev/storefront

Typed client for the Asstio Storefront API. The types are generated from the OpenAPI document committed at tests/Storefront.Api.Tests/Snapshots/openapi-v1.json, which is the contract: it only changes in a deliberate commit, so a change here always shows up in a diff.

npm install @asstio-dev/storefront

Setting a shop up in the first place — the Asstio-side MCP calls, the API key kinds, CORS, the filter and price model — is one document: docs/PUBLIC_STOREFRONT_ONBOARDING.md, served publicly at https://api.storefront.asstio.com/docs/onboarding. This file is only about using the package.

import { createStorefrontClient, filterQuery } from "@asstio-dev/storefront";

// Your key: process.env.STOREFRONT_SITE_KEY on a server, import.meta.env.VITE_… in a bundler.
declare const STOREFRONT_SITE_KEY: string;

const storefront = createStorefrontClient({
  baseUrl: "https://api.storefront.asstio.com",
  siteKey: STOREFRONT_SITE_KEY, // publishable in the browser, secret only on the server
  market: "se",
  acceptLanguage: "sv-SE",
});

const { data, error } = await storefront.GET("/v1/products", {
  params: {
    query: {
      q: "parfym",
      page: 1,
      pageSize: 24,
      sort: "price_asc",
      filter: filterQuery({ brand: ["dior", "chanel"], price: "100-500", size: undefined }),
    },
  },
});

if (error) throw new Error(`${error.code}: ${error.detail ?? ""}`);
console.log(data.total, data.items, data.warnings);
// A product with many variants comes back with variants: null and a variantCount; page them:
const { data: page } = await storefront.GET("/v1/products/{slugOrId}/variants", {
  params: { path: { slugOrId: "manadslins" }, query: { filter: { power: "-1.00" } } },
});
// page.options[].values[].available drives cascading dropdowns: a value reached through the cascade is
// available, but honour the flag for hand-built URLs or restored state, which can select an unavailable one.

Every example in this file is compiled as part of npm run check — the source of them is examples/readme.ts, which also covers product lookup, ETag revalidation, crawling, and image formats.

Images

Products, cards, variants and cart lines carry media descriptors, never URLs. Combine a descriptor with the media block of /v1/site to build src, srcset and <picture> sources. The helpers only ever produce a URL the CDN actually serves: a width and a format the descriptor announces, and the descriptor's own recipe.

import { pictureSources, fileUrl } from "@asstio-dev/storefront";

const { data: site } = await storefront.GET("/v1/site");
const { data: product } = await storefront.GET("/v1/products/{slugOrId}", { params: { path: { slugOrId: "acuvue-oasys" } } });

if (site && product?.image) {
  const { sources, img } = pictureSources(site.media, product.image, "(min-width: 900px) 33vw, 100vw");
  // <picture>{sources.map(s => <source {...s} />)}<img {...img} alt={product.image.alt["sv"] ?? product.name} /></picture>
}
for (const file of product?.files ?? []) console.log(file.filename, fileUrl(site!.media, file));
// A variant with hasOwnImages: false inherits: variant.hasOwnImages ? variant.images : product.images

videoSources returns [] for anything that is not a video, while mediaUrl/pictureSources/fileUrl/posterUrl throw MediaUrlError when the descriptor cannot produce that URL — branch on kind before calling.

Image formats

A site's formats (site.media.formats: card, gallery, thumb, cart, swatch, default, plus custom keys) say how an image is framed: aspect, fit (contain · cover · natural), padding and a background per bg class. They are published with the site, so the look changes without a frontend deploy. formatImage turns a descriptor and a format key into everything a <picture> needs:

import { formatImage } from "@asstio-dev/storefront";

const f = formatImage(site.media, product.image, "card", [
  { media: "(min-width: 1080px)", size: "25vw" },   // your slot's width per breakpoint
  { size: "100vw" },                                // the last one without a media condition
]);
// React:
// <div className={styles.cardImage}>                                  ← your slot: your class, may be a flex/grid item
//   <div style={f.box}>                                               ← the aspect frame, the slot's only child
//     <canvas ref={blurhashCanvas} style={f.placeholder} />           ← optional placeholder, same rectangle as the image
//     <picture>
//       {f.sources.map((s) => <source key={s.type} {...s} />)}
//       <img {...f.fallback} style={f.img} alt={alt} loading="lazy" />
//     </picture>
//   </div>
// </div>

Plain HTML (templating):

const f = formatImage(site.media, product.image, "card", [
  { media: "(min-width: 1080px)", size: "25vw" },
  { media: "(min-width: 640px)", size: "50vw" },
  { size: "100vw" },
]);
// alt is merchant text, not SDK output: escape it for the attribute.
const escapeAttr = (s: string) => s.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
const alt = escapeAttr(product.image.alt["sv"] ?? product.name);
// Your slot owns the width; box is its only child in every fit:
const picture =
  `<picture>${f.sources.map((s) => `<source type="${s.type}" srcset="${s.srcSet}" sizes="${s.sizes}">`).join("")}` +
  `<img src="${f.fallback.src}" srcset="${f.fallback.srcSet}" sizes="${f.fallback.sizes}" width="${f.fallback.width}" height="${f.fallback.height}" alt="${alt}" loading="lazy" style="${styleToCss(f.img)}"></picture>`;
const placeholder = `<canvas width="32" height="32" style="${styleToCss(f.placeholder)}"></canvas>`;
const html = `<div class="card-image"><div style="${styleToCss(f.box)}">${placeholder}${picture}</div></div>`;
  • Element contract. Your slot element owns the width (grid, flex, className); it may be stretched. Put the box inside it as its only child and give the box no padding, border or height of its own — its inline styles pin its geometry, so a stretched slot never distorts the format's aspect.
  • One rectangle. img and placeholder are the same absolutely positioned rectangle with object-fit: fill, computed from the descriptor: a blurhash canvas of any decode size is stretched to exactly where the image will paint. The box's backgroundColor shows around a contain image and through transparent AVIF/WebP.
  • Natural has a box too, at the image's own aspect and without a colour; img and placeholder fill it. So the layout is reserved before load in every fit, and the placeholder sits under the image the same way.
  • sizes is your slots scaled by the image's drawn fraction with calc(); srcset keeps the real widths, so the browser downloads a sharp enough rendition for a cover crop and not a wasted one for a padded packshot.
  • formats: null (a site without formats) or an unknown key falls back to default, then to natural; your own CSS keeps working. focal (set per placement in Asstio) positions a cover crop; showWhole renders a cover format as contain for that image. formatImage throws MediaUrlError for video descriptors, file descriptors, a descriptor without w/h, a descriptor without renditions, or an empty slots array.
  • Plain HTML: see the templating example above, or the compiled function productCard in examples/readme.ts. styleToCss(f.box) gives the style attribute value for any element. Escape anything that is not SDK output (alt, product names) before it goes into markup; the React example needs no escaping because React escapes attribute values.

Product content

description is already sanitized on the server: render description.html as-is — do not sanitize, escape or re-parse it — and use description.text wherever you need plain text (a meta description, a snippet, an aria-label). shortDescription is plain text. brand is the product's brand (the main product's); every variant in variants[] and on /variants carries its own brand and responsible — render the selected variant's. brand.facet links a filtered listing (filter[facet.key]=facet.valueKey) when the shop has a brand filter. brand.url is the brand page on this market and is null whenever that page would not exist here (brand pages off, or nothing of the brand sold on this market) — then render the label (and brand.logo) without a link; never build a brand URL yourself.

attributes[] arrives ordered and labelled for display. Branch on type (text | html | number | bool | list): a list fills values, everything else fills value, and html fills html too — sanitized exactly like description.html.

if (product.description) render(product.description.html); // as-is: sanitized server-side
for (const a of product.attributes) {
  if (a.type === "html") render(a.html!);          // sanitized like description.html
  else if (a.values) render(a.values.join(", "));  // type: "list"
  else render(a.value!);                           // text | number | bool
}

facetKey and valueKeys turn an attribute's values into filter links — filter[facetKey]=valueKeys[i], aligned index-for-index with values (or carrying the single value's key). valueKeys is absent entirely when any one value has no key of its own: a list is either fully linkable or not linkable at all.

related[] holds groups of cards already resolved for this market, and bundle.components[] the cards a bundle is made of; both leave out anything that no longer exists or is not sold here.

Brands and responsible parties

responsible is { manufacturer, importer, euResponsiblePerson }, each a party or null. Show the selected variant's on the product page (EU GPSR manufacturer and responsible-person contact data). null means nothing applies for this SKU: do not fall back to the product's or the brand's. phone is only present when the shop publishes it.

Brand pages: GET /v1/brands lists the brands with a page on this market (paged, by name); GET /v1/brands/{slugOrId} returns the brand's name, description (sanitized like product descriptions), logo, website, its default parties and facet — render the listing with GET /v1/products?filter[<facet.key>]=<facet.valueKey>. A redirectTo means 301 to that slug. GET /v1/sitemap?type=brand lists them for sitemap.xml.

const { data: brand } = await storefront.GET("/v1/brands/{slugOrId}", { params: { path: { slugOrId: "hugo-boss" } } });
if (brand?.redirectTo) redirect(301, `/varumarken/${brand.redirectTo}`);
const { data: listing } = await storefront.GET("/v1/products", {
  params: { query: { filter: filterQuery({ [brand!.facet!.key]: [brand!.facet!.valueKey] }) } },
});

SEO

Pure helpers that compute schema.org JSON-LD and <head> data from the DTOs. They render nothing: your frontend writes the markup. No backend call, no DOM, no dependency.

Product JSON-LD. productJsonLd returns a Product (one variant) or a ProductGroup with one Product and Offer per variant sold on the market. variantUrl is required: Google needs a distinct URL per variant, and opening that URL must preselect the variant on your page. The format is yours; ?variant= below is only an example. variesBy maps your option names to size, color, material or pattern; unmapped options still name the variants. A product with more than 100 variants arrives with variants: null: pass the first /variants page as variants, or the product gets no offer (valid schema.org, but not eligible for product rich results).

const ld = productJsonLd(product, {
  site, market: "se",
  variantUrl: (v) => `${product.url}?variant=${v.id}`,
  variesBy: { Storlek: "size", Färg: "color" },
});
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(ld) }} />

In a plain template: <script type="application/ld+json">${jsonLdScript(ld)}</script>. jsonLdScript escapes <, >, &, U+2028 and U+2029, so merchant text cannot end the script element; never use plain JSON.stringify there.

Breadcrumbs. On a collection page, r.breadcrumb holds the ancestors only. Add the collection itself as current:

const crumbs = breadcrumbJsonLd(r.breadcrumb, { home: { name: "Hem", url: "https://moory.se/" }, current: r.collection });
// null below two items (Google's minimum): render nothing then.

Head. productHead, collectionHead (takes your own description; the API has none for collections) and brandHead return a framework-neutral SeoHead: title, description (cut at 160 characters on a word boundary), canonical, hreflang alternates (keyed language-COUNTRY, x-default from the site's default market; override with xDefault: "<market>" or false), and Open Graph / Twitter meta. titleTemplate: "%s | Moory" applies to <title> only, never to the social titles. In Next.js:

export async function generateMetadata(): Promise<Metadata> {
  return toNextMetadata(productHead(product, { site, market: "se", titleTemplate: "%s | Moory" }));
}

Titles come out as { absolute } on purpose, so a layout's title.template does not apply a second time.

Errors. SeoError means a caller or configuration error: redirectTo is set (answer 301 instead), an unknown market, a bad variesBy value, a variantUrl that is not an absolute http(s) URL or repeats for two variants, or two markets with the same language-COUNTRY and different URLs. Catch it at your SEO boundary and fix the market config. Holes in the data never throw: a missing image, description or price only leaves that part out.

hreflang sets are only as reciprocal as the API's alternateUrls; the SDK renders what the API gives.

Search-as-you-type and search-in-facet

/v1/search/suggest answers three short lists for what the shopper has typed so far: up to 5 product cards (matched on the product name), 5 categories (each with the path to show above it) and 10 values from the facets whose panel is searchable. q is required and 1–100 characters — blank, missing or longer is a 400 (INVALID_QUERY), not a truncation the way a listing's q is.

/v1/products/facets/{key} is the search box inside one facet panel: the facet's values with the counts they have in the same filter[…] context the listing is showing, so a count is what selecting that value would leave. Send the panel's own filters with it, and omit q for the values as they are.

const { data: suggestions } = await storefront.GET("/v1/search/suggest", { params: { query: { q: typed } } });
const { data: brands } = await storefront.GET("/v1/products/facets/{key}", {
  params: { path: { key: "varumarke" }, query: { q: typed, filter: filterQuery({ kon: ["dam"] }) } },
});

The endpoint reads the facet's 1000 most common values and matches q against those — label contains, case-insensitively, or key prefix — returning at most 50. A facet with more distinct values than that is searched over its top 1000; no regular expression ever reaches the search index. key has to be a list or swatch facet: an unknown key is FACET_NOT_FOUND (404), and a range or toggle facet is FACET_NOT_SEARCHABLE (400).

Checkout

Two small modules, because the suspend → update → resume rule spans two runtimes. The server helpers need the client and the cart token (an httpOnly cookie, so they run in Server Actions and route handlers); mountCheckout needs neither key nor fetch — it mounts the provider's widget and drives it from the answers your server hands it.

import { awaitCompletion, completeCheckout, getCheckoutStatus, getOrder, mountCheckout, startCheckout, syncCheckout } from "@asstio-dev/storefront";

Server. startCheckout(client, cartToken, { option?, returnOrigin? }) creates the cart's checkout attempt or converges on the live one; call it from a Server Action or route handler on the shopper's navigation to the checkout page, never during a Server Component's render (a re-render would create a provider order per mutation). syncCheckout is the same POST /v1/checkout, retrying CHECKOUT_IN_PROGRESS per Retry-After twice by default — call it after every cart mutation on the checkout page, through the browser handle's update; there, and for the handle's sync, pass { retries: 0 } so each call is one POST and the handle's own burst does the retrying. getCheckoutStatus(client, attemptId, cartToken) is the non-mutating GET /v1/checkout/{attemptId} behind the handle's status callback. completeCheckout(client, attemptId, cartToken?) is the confirmation page's call; with the cart token the answer carries order.accessToken, on every call, so a lost first answer or a reload still ends with the token. awaitCompletion(() => completeCheckout(…)) polls with backoff until the order exists. getOrder(client, orderId, token) sends the token as X-Order-Token — a header, never a query string; send Referrer-Policy: no-referrer on the order page and keep its URL out of your logs.

// A Server Action or route handler, on the shopper's navigation to the checkout page.
const result = await startCheckout(storefront, cartToken, { returnOrigin: "https://shop.example" });
if (!result.ok) return { problem: result.problem, retryAfter: result.retryAfter };
return { checkout: result.data };   // "open" with a widget, or "completed"/"pending" with an order

// The Server Action behind mountCheckout's `sync`, and the POST inside every cart update: one HTTP POST per call.
export async function syncForBrowser() {
  return syncCheckout(storefront, cartToken, undefined, { retries: 0 });
}

// The confirmation page: complete with the cart token, keep the order's access token, then poll for the number.
const first = await completeCheckout(storefront, attemptId, cartToken);
const done = await awaitCompletion(() => completeCheckout(storefront, attemptId, cartToken));

// The order page: the token is a header.
const order = await getOrder(storefront, orderId, token);   // 404 ORDER_NOT_FOUND for an unknown id and a wrong token alike

Browser. mountCheckout(container, checkout, { sync, status, onProblem, onOrder }) takes the open answer your page was rendered with and returns a handle { attemptId, suspended, suspend, resume, remount, destroy, sync(update) }. The container is a client component keyed by attemptId. handle.sync(update) is the whole rule: it suspends the widget, runs your update once (mutate the cart, POST /checkout, return its answer), then dispatches status first, identity on open: completed/pending → onOrder(order, attemptId) (you navigate); locked → stays suspended and retries sync() per Retry-After within the API's budget (30 s, ten calls — a 429 counts the same; a Retry-After past the 30 s is not waited out), then polls status(attemptId); processing → polls status(); declined/expired → one sync(); open → resumes when the answer's attemptId is the mounted one, else remounts from the answer's widget (changed only says this call created a new attempt — never resume on it). A status() answer is never dispatched as payable: its open leads to one non-mutating sync(). An update that throws leaves the widget suspended. The widget is suspended the moment handle.sync is called, and stays suspended while another update is queued behind it; a newly queued update ends the running sync's waits (a retry or a status poll), so it runs at once. CHECKOUT_IN_PROGRESS is retried like locked, within the same burst. If the provider's script loads late, the handle keeps trying to bind its decline signal every half second for as long as the widget is mounted. A 503 CHECKOUT_UNAVAILABLE or CHECKOUT_PROVIDER_UNAVAILABLE is retried per its Retry-After by non-mutating syncs within the same budget, then reported; any other 503 (SITE_KEYS_SYNCING, SITE_REGISTRY_WARMING) and every other non-2xx of the SDK's own calls reaches onProblem(problem, { status }) at once, status being the HTTP status — a 409 CHECKOUT_CART_CHANGED carries the corrected cart in problem.cart: show it and its warnings, then handle.sync(() => POST /checkout). handle.sync never rejects. onProblem also receives the SDK's own codes: SDK_SYNC_FAILED or SDK_STATUS_FAILED when one of its calls throws (a network failure), SDK_UPDATE_FAILED when your update throws, and SDK_DISPATCH_FAILED for any other throw while dispatching (an onOrder that throws, an unknown widget controller) — each with { status: 0 } and the widget left suspended; SDK_CHECKOUT_CLOSED when the one sync() after a declined/expired answers closed again, and SDK_UNKNOWN_STATUS for an answer status the SDK does not know (both { status: 200 }); and SDK_EMPTY_ANSWER, which the server helpers return when an answer carried no body (with that answer's HTTP status). The SDK also syncs by itself: once on the provider's payment-declined signal where its JavaScript offers one, when the page becomes visible again while suspended, and three minutes before expiry, timed from the DTO's expiresInSeconds (server-relative, so a browser clock ahead of the server's still lands inside the server's five-minute window). An autonomous sync is skipped while another sync is running or queued.

// A client component, keyed by attemptId so a re-render never resets it.
const handle = mountCheckout(container, checkout, {
  sync: callSync,       // a Server Action calling syncForBrowser()
  status: callStatus,   // a Server Action calling getCheckoutStatus(storefront, attemptId, cartToken)
  onProblem: (problem) => { /* problem.cart when code === "CHECKOUT_CART_CHANGED"; otherwise the message for problem.code */ },
  onOrder: (order, attemptId) => { location.assign(`/kassa/tack?attempt=${attemptId}&order=${order.id}`); },
});
// Every cart mutation on the page goes through the handle: suspend → your update once → status first, identity on open.
await handle.sync(() => callAddLine(sku));   // callAddLine mutates the cart, then syncs, and returns that answer

After completion. Every cart endpoint answers 410 CART_EXPIRED for the converted cart, while the cart token is still what re-delivers the order's access token — so a 410 from a cart mutation on the checkout or confirmation path does not end the cart cookie: call GET /v1/checkout/{attemptId} (or POST /checkout, which answers the converted cart's completed attempt) with the same token, navigate to the confirmation from its order, and clear the cookie only once the confirmation page holds the access token.

Copy and CSP. The pending state ("awaiting payment approval") is your copy. Each provider's widget needs its domains in your CSP: Qliro One *.qliro.com (pago.qit.nu in the sandbox), Klarna/Kustom *.klarna.com (*.kustom.co), Walley *.walleypay.com, plus their frame-src/script-src/connect-src — the provider's integration guide lists the exact hosts.

Problem codes you will meet here: CART_EMPTY, CHECKOUT_CART_CHANGED (with cart), CHECKOUT_PREVIEW_KEY, CHECKOUT_MARKET_MISMATCH (switch the cart's market first, then sync), OPTION_NOT_AVAILABLE, ORIGIN_NOT_ALLOWED, CHECKOUT_UNAVAILABLE and CHECKOUT_PROVIDER_UNAVAILABLE (503, Retry-After), CHECKOUT_IN_PROGRESS (409, Retry-After), CHECKOUT_PROVIDER_REJECTED (502), CHECKOUT_NOT_FOUND, ORDER_NOT_FOUND, SITE_KEYS_SYNCING (503 for a secret key on a freshly created site, Retry-After).

Qliro (qliro-q1)

  • The widget is Qliro's HTML snippet; mountCheckout re-creates its scripts so they run.
  • After every cart change, handle.sync(update) locks the widget (q1.lock()), and resumes it through q1.onOrderUpdated, which makes the iframe re-read the order. The widget unlocks only when Qliro's order shows the confirmed total. If it does not within 10 seconds, or the change came before Qliro's script had loaded, the widget is re-rendered from the confirmed checkout instead; it is never unlocked before Qliro shows the confirmed total.
  • When our validation declines a purchase (the cart moved on, the attempt expired, a line is out of stock), Qliro shows its out-of-stock message and fires q1.onPaymentDeclined; the SDK then syncs by itself and remounts or resumes.

Headers

Every call the client makes carries these; you never set them by hand.

| Header | Required | Meaning | | --- | --- | --- | | X-Site-Key | yes | Which shop, and with which visibility. sfk_pub_… publishable (safe in a browser bundle), sfk_prv_… preview (draft content), sfk_sec_… secret — server-side only: a secret key sent from a page is rejected with SECRET_KEY_FROM_BROWSER. | | X-Market | no | Market code, e.g. se. Omit for the site's default market. An unknown one is MARKET_UNKNOWN. | | Accept-Language | no | BCP-47 tag. Omit to use the market's language. |

The client sets all three, so params.header is optional at every call site even though the contract marks X-Site-Key required.

Errors

Every failure is application/problem+json with a stable machine-readable code; branch on code, never on title or on the status alone.

{
  "type": "https://docs.asstio.com/storefront/errors#PRODUCT_NOT_FOUND",
  "title": "PRODUCT_NOT_FOUND",
  "status": 404,
  "detail": "No product 'x' on this market.",
  "code": "PRODUCT_NOT_FOUND"      // the only stable field; `retryAfterSeconds` is added on RATE_LIMITED
}

It is StorefrontProblem in the schema, so error.code and error.retryAfterSeconds are typed — no cast:

if (error?.code === "RATE_LIMITED") await sleep((error.retryAfterSeconds ?? 10) * 1000);

Every operation documents 400, 401, 403, 404, 429 and 503; the entity 404s (PRODUCT_NOT_FOUND, COLLECTION_NOT_FOUND) are called out on the operations that raise them.

Codes you will meet: SITE_KEY_MISSING, SITE_KEY_INVALID, SITE_INACTIVE, SECRET_KEY_FROM_BROWSER, SITE_REGISTRY_WARMING, MARKET_UNKNOWN, RATE_LIMITED (with retryAfterSeconds, plus a Retry-After header), PRODUCT_NOT_FOUND, COLLECTION_NOT_FOUND, INVALID_PAGE, INVALID_SINCE, INVALID_TYPE, INDEX_UNAVAILABLE (retry — a preview index is being rebuilt), NOT_FOUND, INVALID_QUERY, FACET_NOT_FOUND, FACET_NOT_SEARCHABLE, and, from the checkout (above): CART_EMPTY, CHECKOUT_CART_CHANGED, CHECKOUT_PREVIEW_KEY, CHECKOUT_MARKET_MISMATCH, OPTION_NOT_AVAILABLE, ORIGIN_NOT_ALLOWED, CHECKOUT_UNAVAILABLE, CHECKOUT_PROVIDER_UNAVAILABLE, CHECKOUT_IN_PROGRESS, CHECKOUT_PROVIDER_REJECTED, CHECKOUT_NOT_FOUND, ORDER_NOT_FOUND, SITE_KEYS_SYNCING, CART_EXPIRED.

SITE_REGISTRY_WARMING and INDEX_UNAVAILABLE are retries, not bugs in your call, as are the checkout's CHECKOUT_IN_PROGRESS, CHECKOUT_UNAVAILABLE, CHECKOUT_PROVIDER_UNAVAILABLE and SITE_KEYS_SYNCING (each with Retry-After).

Warnings

A request that was almost right still answers 200 and says so in warnings[], so a listing page never goes blank because of a bad query string:

| code | What happened | | --- | --- | | SORT_UNKNOWN | The sort value is not one this site offers; the default sort was used. | | COLLECTION_UNKNOWN | The named collection does not exist; it was not applied. | | FILTER_IGNORED | A filter[…] key is not facetable here; that filter was dropped. | | QUERY_TRUNCATED | q was longer than 200 characters; the results are for the shortened term. |

Query parameters

q, page, pageSize, sort and filter are all in the contract, so they are typed and autocompleted. filter is a deepObject: { brand: "dior,chanel" } goes out as filter[brand]=dior,chanel, which is the form the API reads. In a list, swatch or hierarchy filter , separates values, \, is a comma inside a value and \\ a backslash — value keys are raw values, and an option such as 14,2 is the key 14,2. Never join or split a filter parameter yourself: encodeFilterValues(values) builds one and decodeFilterValues(param) reads one (for the selected state of a URL you parse), for every facet alike. filterQuery() uses encodeFilterValues for multi-select arrays, passes a single string (a range 100-500, a toggle true) as given and drops undefined ones. A range is min-max: either bound may be left out and either may be negative — -10--2, -10-2, -10- (at least −10), --2 (at most −2) — and a leading dash with nothing before it keeps its meaning, -500 is "at most 500". A range whose lower bound is above its upper bound is ignored with a warning.

/v1/sitemap takes type (product | collection), since (ISO-8601, inclusive), page and pageSize (max 1000, default 500).

/v1/search/suggest takes q only, and /v1/products/facets/{key} takes q plus the same filter object as a listing — both are described under Search-as-you-type and search-in-facet.

Caching

Read responses carry an ETag and Vary: X-Site-Key, X-Market, Accept-Language — the answer depends on all three, so a shared cache must key on all three. Send the ETag back as If-None-Match and a matching request answers 304 Not Modified with no body:

const first = await storefront.GET("/v1/site", {});
const etag = first.response.headers.get("ETag")!;
const again = await storefront.GET("/v1/site", { headers: { "If-None-Match": etag } });
// again.response.status === 304

Errors are never stored (Cache-Control: no-store) and never carry an ETag.

Development

From sdk/typescript:

npm ci
npm run build     # regenerates src/schema.d.ts from the snapshot, compiles, then typechecks the examples
npm run check     # fails if the committed schema.d.ts is stale, modified or untracked, or an example broke

src/schema.d.ts is generated and committed: it is reviewed in pull requests exactly like the OpenAPI snapshot it comes from. After changing the public API, regenerate the snapshot from the repo root, then build and check the SDK:

UPDATE_SNAPSHOTS=1 dotnet test --project tests/Storefront.Api.Tests -- --filter-query "/*/*/OpenApiSnapshotTests/*"
cd sdk/typescript
npm ci
npm run build
npm run check

Review and commit tests/Storefront.Api.Tests/Snapshots/openapi-v1.json and sdk/typescript/src/schema.d.ts together. An API or worker implementation change that leaves the OpenAPI snapshot unchanged does not require a generated SDK change.

Publishing

The SDK and backend share a release version. Do not edit the placeholder version in package.json and do not run npm publish locally. Pushing a repository tag such as v1.2.3 runs publish-npm.yml, which derives version 1.2.3 from the tag, installs and checks the package, runs a publish dry run, and creates a pending staged package through npm trusted publishing. No long-lived NPM_TOKEN is used.

The tag does not make the package public. A maintainer must inspect and approve it with npm 2FA, on the npm website under Staged Packages or with npm 11.19 or newer:

npm stage list @asstio-dev/storefront
npm stage view <stage-id>
npm stage approve <stage-id>

After approval, verify the registry version and a clean consumer install. The full API, worker, and npm release checklist is in docs/ops/deploy.md.