@scayle/storefront
v0.1.0-alpha.1
Published
SCAYLE Storefront Runtime for Inertia based storefront applications
Maintainers
Keywords
Readme
@scayle/storefront
V3 runtime framework for SCAYLE Storefront Applications.
Package entrypoints
| Subpath | Use |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @scayle/storefront | Server runtime values: middleware factories (createRedirectMiddleware), routers, Inertia, Session, config, ShopConfigService, server utilities (generateKey, HttpStatusCode, price helpers, renderErrorPage, createStorefrontErrorHandler), shop switcher URL helpers (getCountryUrl, getAvailableCountries, getAvailableLanguages), and static asset helpers. |
| @scayle/storefront/config | defineStorefrontConfig only. Use this in storefront.config.ts so the module graph does not pull virtual:storefront-config before your config file finishes loading. |
| @scayle/storefront/client | Client-safe auth helpers (clearCheckoutAuthStorage, CHECKOUT_AUTH_STORAGE_KEYS, getCheckoutAuthApi), browser oRPC setup (createBrowserOrpcClient), and getCurrentCountryFromTimezone. |
| @scayle/storefront/shared | createLogger, link or canonical URL helpers, isomorphic listing helpers (buildPagination, filter query utils, resolveSorting), currency/percentage/reduction formatting (formatCurrency, formatPercentage, getReduction), country redirect helpers (flattenRedirectShops, getRedirectShopsForRegion, evaluateShopRedirectSuggestion), and CHECKOUT_DEFERRED_PROP_KEYS. Use this in client code. |
| @scayle/storefront/types | Type-only re-exports: StorefrontContext, StorefrontVariables, config and country types, country detection types (AvailableCountry, AvailableLanguage, RedirectShop, StorefrontCountryWithUrl), Inertia contracts, SEO types, session types, GetReductionInput (the parameter type of getReduction and of the formatReduction mapper hooks), and related API types. |
Deferred prop resolver failures
Inertia partial reloads in @scayle/storefront resolve every deferred prop with Promise.allSettled,
so one rejected resolver does not poison sibling props in the same group. Each rejection is logged
once with inertia.prop.name, the failed key is omitted from the response, and the Inertia client
keeps the <Deferred> fallback visible for that key. Full page renders and full Inertia navigations
still fail fast when a prop resolver throws.
This is a safety net, not a recovery mechanism. Resolvers for non-critical props should wrap their
work in try/catch and return a sensible fallback (an empty basket, an empty recommendations list)
so the UI degrades gracefully instead of staying on a loading skeleton:
basket: Inertia.defer(async () => {
try {
return await basketService.fetchBasket(...)
} catch (err) {
log.error({ message: 'basket fetch failed', err })
return basketService.emptyBasket()
}
}, { group: 'user-data' })For critical props (the ones whose absence should fail the page render), let the resolver throw and the framework will propagate the error.
Server utilities (@scayle/storefront)
Pure helpers consumed by boilerplate and feature-package server code. Tree-shakable; safe to import in any file under src/server/**.
import {
generateKey,
calculateOriginalFromReductions,
hasCampaignReductionFromStrikeThrough,
} from '@scayle/storefront'| Export | Purpose |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| generateKey | Stable basket/wishlist cache key derived from shop, session, and (optional) user identifiers. |
| calculateOriginalFromReductions | Sums appliedReductions on top of withTax to recover the pre-reduction price (cents). |
| hasCampaignReductionFromStrikeThrough | Predicate over a strike-through ladder; true when at least one entry has category === 'campaign'. |
| renderErrorPage | Renders the boilerplate Error page for an uncaught request error. In dev, attaches a Youch technical-details report for 5xx; never leaks the cause or stack to the client in production. |
| createStorefrontErrorHandler | Builds the Hono onError handler tenants wire up via app.onError(...). Logs uncaught errors as one structured entry, passes HTTPExceptions through, and renders the Error page. |
Product helpers (
mapSellableTimeframe,SellableTimeframe,getPrimaryCategory, low-stock and attribute utilities) are exported directly from@scayle/storefront(values from the package root, types from@scayle/storefront/types).
Redirect middleware
Register after the storefront runtime middleware so ctx.get('storefront') is available:
import { createRedirectMiddleware } from '@scayle/storefront'
app.use(createRedirectMiddleware())| Export | Purpose |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| createRedirectMiddleware | Hono middleware that runs after downstream handlers. On 404 only, normalizes the request URL and looks up panel-managed redirects via SAPI POST /v1/redirects. Responds with the configured status and Location when a match is found. Caches hits and misses for five minutes. Preserves the original 404 if lookup fails. |
Optional queryParamAllowlist controls which query params participate in the lookup URL. Params not in the list are stripped from the lookup but forwarded to the redirect target.
Server-side cache (CacheService)
The runtime exposes ctx.get('storefront').cache as a CacheService backed by the shared unstorage instance.
See the boilerplate Storage and Caching guide for wiring and examples.
In production SSR bundles, import.meta.env.SCAYLE_BUILD_ID is injected by @scayle/storefront-build and is
included in every physical cache key after the cache prefix and before the shop id. After a deployment the build
id changes, so lookups no longer hit KV entries from the previous build. That avoids returning stale data when
mapper output or serialization changes. The first request for a logical key after a deploy misses and repopulates
the cache (for example via getOrSet). Entries written under an older build id are not deleted immediately. They
remain until their TTL expires, then drop off on their own.
In development, SCAYLE_BUILD_ID is usually unset, so keys match the historical shape cache:{shopId}:{key}.
Logger
Logging uses tslog behind @scayle/storefront/shared. The root logger name is storefront. Use
createLogger with domain-based namespaces, for example auth, search, or checkout.
Do not repeat a storefront prefix in your namespace string.
Imports
import { createLogger } from '@scayle/storefront/shared'Namespace usage
Use createLogger with stable, domain-oriented names so logs stay easy to query across the app.
Namespace depth is limited to 2 segments in the final logger name. Since tslog already
uses storefront as root, pass only one segment: domain.
const log = createLogger('redirects')
log.debug({ message: 'Cache miss', path })Example:
const log = createLogger('search')Do not add extra segments with getSubLogger, because it would exceed storefront:domain.
Log payload shape (JSON mode)
tslog merges a log call into a JSON object. From the lifecycle docs:
if there is only one argument and it is an object, it merges with the default log object. If there is more
than one argument, they become "0", "1", and so on, which is awkward for Datadog and similar tools.
Convention: pass one object per call. Put the human-readable line in message, errors in err, and extra
context as sibling fields (do not interpolate identifiers into message when you want them filterable).
log.error({ message: 'Redirect lookup failed', err: error })
log.error({ message: 'Failed to fetch navigation', err: error, referenceKey })
log.error({ message: 'Failed to warm order summary cache', err: error })
log.error({ message: 'Failed to invalidate order summary cache', err: error })The custom JSON transport overrides tslog's default to match the OTel log collector contract used across the storefront fleet. Each line carries:
timestamp— ISO 8601 string from the log entry's date.severity— lowercase severity text (debug,info,warn,error).logger— colon-joined namespace (for examplestorefront:redirects), omitted when empty.- All other payload fields keep their position. tslog's
_logMetaenvelope is dropped.
Error instances are serialized as { name, message, stack, cause, ...ownProps } so stack traces survive
the wire (the default JSON.stringify returns {} because Error properties are non-enumerable). cause
chains are walked recursively; self-referential or mutually referential causes short-circuit with
[Circular].
Edge cases the replacer handles so structured payloads do not throw on serialization:
bigintbecomes its decimal string (7n→"7").undefinedbecomes"[undefined]".- True cycles in plain objects become
"[Circular]". Shared (non-cyclic) references are preserved on every visit, so logging the same object twice in one payload still serializes both copies.
Environment variables
Configuration is read from Node process.env and, when the app exposes them, from Vite
import.meta.env (for example with envPrefix: 'STOREFRONT_' so the same keys work in the browser).
| Variable | Purpose | Values |
| ---------------------------- | -------------------------------- | -------------------------------------------------------------------------------------- |
| STOREFRONT_LOG_FORMAT | tslog output shape | Optional. pretty for readable lines. Otherwise json (default). |
| STOREFRONT_LOG_LEVEL | Minimum log level | Optional. debug, info, warn, or error. Invalid or unset defaults to debug. |
| STOREFRONT_LOG_REDACT_KEYS | Extra keys to redact in payloads | Optional. Comma-separated list of key names, merged with the default denylist. |
Examples:
STOREFRONT_LOG_FORMAT=json
STOREFRONT_LOG_LEVEL=info
STOREFRONT_LOG_REDACT_KEYS=idToken,customSecretIn a Vite app, expose the same STOREFRONT_* keys on import.meta.env if you want client-side reads (for
example using Vite envPrefix: 'STOREFRONT_'). See readEnvString in src/logger/tslog.ts.
Sensitive data redaction
The root logger is configured with tslog's mask.keys, so values attached under known sensitive
keys are replaced with [REDACTED] before output.
Default keys (see DEFAULT_REDACT_KEYS in src/logger/tslog.ts):
password, token, secret, jwt, code, authorization, cookie,
apiKey, apiToken, clientSecret, accessToken, refreshToken,
checkoutJwt, sessionId, access_token, refresh_token, setCookieExtend the list per deployment via STOREFRONT_LOG_REDACT_KEYS (comma-separated). Entries merge with the
defaults, duplicates are ignored.
For redaction to actually kick in, pass log data as a structured object, not a pre-stringified JSON blob. tslog walks the object and masks matching keys, it cannot reach into a string.
log.error({ message: 'onCheckoutError', userId, checkoutJwt, payload })
log.error({
message: 'onCheckoutError',
detail: JSON.stringify({ checkoutJwt }),
})The first call logs checkoutJwt: "[REDACTED]". The second leaks the token inside detail.
Redaction applies to the OpenTelemetry log pipeline as well: TslogLogRecordExporter forwards each record
through the same root logger, so logRecord.attributes and logRecord.body are masked on the way out.
Observability
Render-pipeline span shape
A full SSR render produces this span tree:
http.server.request [instrumentation-http, entry]
└─ Internal: GET /<route> [ssr] [@scayle/opentelemetry middleware, route-named]
├─ inertia_resolve_prop × N [@scayle/storefront]
│ └─ http.client.request [instrumentation-undici]
│
├─ inertia_render [@scayle/storefront]
│ └─ vue_ssr_render [@scayle/opentelemetry helper, via boilerplate ssr.ts]
│
└─ inertia_compose_html [@scayle/storefront]inertia_compose_html is a sibling of inertia_render because the work runs after serverRenderer.render() returns. Sequential, not nested.
Span attribute reference
| Span | Attributes |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| inertia_resolve_prop | inertia.prop.kind, inertia.prop.name |
| inertia_render | inertia.component |
| inertia_compose_html | inertia.html_bytes |
| vue_ssr_render (helper from @scayle/opentelemetry) | vue.html_bytes |
| http.client.request (undici) | http.request.body.size, http.response.body.size, http.response.header.content_encoding |
*_bytes attributes are read from already-existing strings (rendered HTML, composed page, wire headers). No extra serialization.
Boilerplate traceSsrRender integration
The vue_ssr_render span is emitted by the boilerplate's SSR entry, not by this package. The boilerplate's src/client/ssr.ts wraps Vue's renderToString with traceSsrRender from @scayle/opentelemetry:
import { renderToString } from '@vue/server-renderer'
import { traceSsrRender } from '@scayle/opentelemetry'
createInertiaApp({
render: (app) => traceSsrRender(() => renderToString(app)),
// ...
})The helper lives in @scayle/opentelemetry so this package does not gain a vue dependency.
