@zenky/storefront-vue
v0.10.10
Published
Zenky Storefront SDK for Vue 3.
Maintainers
Readme
@zenky/storefront-vue
Headless storefront engine for Zenky — Vue 3 + Pinia, no framework lock-in.
One engine behind many storefronts. The cart, order building, checkout, catalog resolution, delivery slots, SEO/JSON-LD, theme, and store/profile state all live here as framework-agnostic composables and Pinia stores. The consumer — a Nuxt site, a QR-code menu, a new template — brings the framework glue (cookies, config, toasts, HTTP transport, useHead) and draws its own UI on top.
The design is Ports & Adapters: the core declares small ports for everything it needs from the outside world, the consumer provides adapters once, and every composable reads them through useRuntime(). The core never imports useCookie, useRoute, useHead, $fetch, or anything Nuxt-specific.
Installation
npm install @zenky/storefront-vuePeer dependencies (the consumer already has these):
{
"vue": "^3.5.0",
"pinia": "^3.0.0",
"@zenky/storefront-api": "^1.0.1",
"@zenky/integrations-sdk": ">=0.1.3"
}Quick Start
Assemble the runtime once at your composition root and install it as a Vue plugin. After that, core composables just work.
import { createStorefront } from '@zenky/storefront-vue'
app.use(
createStorefront({
sdk, // ZenkyStorefront instance (one per app)
config: { baseUrl, baseAuthUrl },
storage: { // where order id/token, settings, etc. persist
get: (key) => readCookie(key),
set: (key, value, opts) => writeCookie(key, value, opts),
remove: (key, opts) => deleteCookie(key, opts),
},
notifier: { // user-facing messages
error: (m) => toast.error(m),
success: (m) => toast.success(m),
warning: (m) => toast.warning(m),
message: (title, opts) => toast(title, opts),
},
messages: { // consumer-owned localization
format: (key, params) => i18n.t(key, params),
},
activity: { // busy indicators for long operations
begin: (kind) => busy.begin(kind),
end: (kind) => busy.end(kind),
},
}),
)Then, anywhere in a component or composable:
import { useStorefrontCart } from '@zenky/storefront-vue/cart'
const cart = useStorefrontCart()
await cart.initializeOrder()
await cart.addToCart({ productId, productVariantId, quantity: 1 })
cart.itemCount.value // reactive
cart.totalPrice.valueCall composables and stores synchronously in setup
Composables read the runtime through inject(), which works while a component
instance is active or while the owning Vue app is executing app.runWithContext().
The important cases behave differently:
- Top-level
awaitin<script setup>is safe. The SFC compiler wraps it inwithAsyncContext(), which restores the instance afterwards — so composables called afterawait bootstrap()at the top level still resolve correctly. - A lifecycle hook has context only in its synchronous prefix. After its first
await, the component instance is no longer active. - Event handlers,
watchcallbacks, timers, and.then()callbacks do not inherit component injection context. Resolve dependencies before registering the callback, or deliberately re-enter the owning app withapp.runWithContext(). - A hand-written
async setup()is not compiler-wrapped. Resolve dependencies before its firstawait.
Resolve the composable up front and call the returned functions later.
// ✅ resolve in setup, call after the await
const { dispatchCheck } = usePromotionsChecker()
onMounted(async () => {
await initializeOrder()
dispatchCheck()
})
// ❌ resolving after an await — inject() has no instance to read from
onMounted(async () => {
await initializeOrder()
const { dispatchCheck } = usePromotionsChecker()
})The same applies inside event handlers, watch callbacks, timers, and .then() blocks:
resolve in setup(), keep the reference, call it from the callback. Watch for this
inside try blocks in particular — the catch swallows the failure and reports it as
whatever the surrounding operation was supposed to do, so the real cause never surfaces.
Stores fail differently, and worse. The Pinia stores exported from
@zenky/storefront-vue/stores read the runtime too, but a missed context does not throw:
Pinia falls back to its module-level active instance. Under concurrent SSR that instance
belongs to whichever request touched it last, so the lookup succeeds with another user's
state instead of failing.
Two cases behave differently:
- Inside a store action — safe. Pinia activates the action's own instance for the duration of the call, so resolving another store there returns the right one.
- In a plain function after an
await, outside any action and outsiderunWithContext()— leaks. This is the shape to watch for: it is silent, and it crosses requests.
Resolve core stores in setup() for the same reason you resolve composables there. A
silent leak is far harder to notice than an exception.
Runtime Ports
createStorefront(runtime) takes one object. Five ports are required; the rest are optional — omit an optional port and the feature that needs it is simply skipped.
| Port | Required | What the consumer provides |
| --- | --- | --- |
| sdk | yes | A ZenkyStorefront instance (@zenky/storefront-api). |
| config | no | Reserved consumer configuration; the current core does not read it. |
| storage | yes | Key/value persistence — cookies, localStorage, memory. |
| notifier | yes | Toasts: error / success / warning / message. |
| messages | yes | Maps stable domain message keys to localized consumer copy. |
| activity | yes | begin(kind) / end(kind) for busy state. |
| events | no | Lazy { get() } provider for ZenkyIntegrationsSDK; resolve on every event. |
| realtime | no | Lazy { get() } provider for the WebSocket adapter (subscribe / unsubscribe / isReady). |
| catalogReloader | no | Reloads the catalog when the stock changes. |
| rum | no | Performance marks (mark / measure). |
| commerceEvents | no | Commerce-event tracking (trackAddedToCart, getIdentity, …). |
Types for every port live in the ports subpath, alongside no-op defaults and an in-memory storage helper for SSR/tests:
import {
type StorefrontRuntime,
type StorageAdapter,
KEY_MESSAGE_FORMATTER,
NOOP_NOTIFIER,
NOOP_ACTIVITY_REPORTER,
createMemoryStorage,
} from '@zenky/storefront-vue/ports'The full public contract, adapter examples, SSR lifecycle, and security boundary are in Runtime Ports. For a step-by-step consumer integration and upgrade checklist, see Consumer Setup. For a map of what lives in each domain folder and how to decide whether new logic belongs here or in the consumer app, see Architecture.
Subpath Imports
Each public domain is its own entry point, so bundlers only pull in what you import.
import { minorToMajor } from '@zenky/storefront-vue/money'
import { buildCssVariableMap } from '@zenky/storefront-vue/theme'
import { getRemoteCatalog, findCategoryByShortId } from '@zenky/storefront-vue/catalog'
import { useStorefrontCart, useCheckoutSubmit } from '@zenky/storefront-vue/cart'
import { generateFreeTimeGroups } from '@zenky/storefront-vue/order'
import { buildProductSchema, buildOgTags } from '@zenky/storefront-vue/seo'
import { usePaginatedList } from '@zenky/storefront-vue/pagination'Available subpaths: ports, money, utils, theme, stores, products, catalog, cart, order, promotions, seo, pagination, snippets, website-settings.
Routing, tenant theme presets, localized copy, header/footer composition, and conversion to
framework-specific head records belong to the consumer. Core SEO builders return plain
records; a Nuxt adapter may add Unhead-specific fields such as tagPriority.
Runtime Compatibility
- ESM-only, targeting ES2022.
- Vue 3.5+ and Pinia 3.
- Node.js 20.19+ for SSR and package tooling; Node.js 22 is recommended for production and development.
- Modern browsers and edge runtimes with standard Web Platform APIs.
CI imports every public entry in Node SSR and scans the emitted ESM for CommonJS, Node
built-ins, Buffer, and process. Browser-only behavior is guarded; for example,
online-payment retry accepts an explicit currentUrl when no window is available.
For SSR, create a new Vue app, Pinia instance, storefront runtime, and storage adapter per
request. Never share createMemoryStorage() between users.
Package Size
Every domain has a tree-shakeable subpath. CI measures the transitive gzip graph for the root, cart, and utils entries plus all emitted JavaScript. The budgets are regression ceilings, not size targets; increasing one requires review and a release-note explanation.
npm run build
npm run check:sizePinia Stores
The stores subpath exposes setup-style Pinia stores (ids namespaced zenky-*): cart, order settings, store profile, catalog, products, auth, promotions. They are plain state containers — no API calls, no UI logic — and they are the reactive backbone the composables read from. Install Pinia before mounting the storefront plugin.
import { useCartStore, useStoreProfileStore } from '@zenky/storefront-vue/stores'Money
Prices are stored and computed in minor units (kopecks) everywhere. Convert only at the API boundary.
import { minorToMajor, minorToMajorString, majorToMinor } from '@zenky/storefront-vue/money'
minorToMajor(65000, 'RUB') // 650 — numbers for bill/amount fields
minorToMajorString(65000, 'RUB') // "650.00" — strings where the API expects them
majorToMinor(650, 'RUB') // 65000 — user input back into the storeDevelopment
npm run dev # tsup --watch
npm run build # ESM + .d.ts to dist/
npm run lint # Biome lint
npm run format # Biome format --write
npm run typecheck # tsc --noEmit
npm test # vitest
npm run test:coverage # vitest + V8 coverage report
npm run check # format + lint + types + tests + build + exports
npm run check:exports # publint + are-the-types-wrong (esm-only profile)
npm run check:boundaries # reject Nuxt/Unhead and consumer policy in src/
npm run check:context # reject ambient composables/stores in deferred helpers
npm run check:runtime # import all entries in SSR + reject Node-only output
npm run check:size # enforce gzip bundle budgetsPackage Scripts
build— bundle the multi-entry ESM package with tsup.dev— rebuild on change.test/test:watch— run the Vitest suite.typecheck— type-only compile.check:exports— validate the exports map and published types.check:boundaries— reject Nuxt/Unhead APIs, routes, and tenant presets in core source.check:context— reject deferred composable/store resolution outside composition setup.check:runtime— verify Node SSR imports and browser/edge-safe ESM output.check:size— enforce root/domain/all-output gzip budgets.check:ci— quality gate with the V8 coverage ratchet.prepublishOnly— run the release quality gate.
Contributing and Releases
Read CONTRIBUTING.md before opening a pull request. User-visible changes belong in CHANGELOG.md; maintainer steps are documented in the release process.
Security vulnerabilities must be reported privately according to SECURITY.md, never through a public issue.
License
MIT
