@ovineko/spa-guard
Core runtime for spa-guard — chunk load error handling, version checking, spinner, i18n, and event schema for SPAs.
Install
- pnpm
- npm
- yarn
- bun
- deno
pnpm add @ovineko/spa-guard
npm install @ovineko/spa-guard
yarn add @ovineko/spa-guard
bun add @ovineko/spa-guard
deno add npm:@ovineko/spa-guard
Usage
Call recommendedSetup once when your app boots. It dismisses the loading spinner, starts version checking, and returns a cleanup function.
import { recommendedSetup } from "@ovineko/spa-guard/runtime";
const cleanup = recommendedSetup();
// optionally call cleanup() to stop background checks
recommendedSetup is idempotent. Repeated calls return the same cleanup function and do not re-run setup side effects.
By default, recommendedSetup uses healthyBoot: "auto":
- if retry params exist in the URL, it waits a dynamic grace period and calls
markRetryHealthyBoot()only when the orchestrator phase is stillidle. - if no retry params exist, it does nothing.
Auto grace period formula:
max(5000, max(reloadDelays)+1000, sum(lazyRetry.retryDelays)+1000)
If you want strict control, switch to manual healthy-boot mode and call markRetryHealthyBoot() yourself.
import { markRetryHealthyBoot } from "@ovineko/spa-guard";
import { recommendedSetup } from "@ovineko/spa-guard/runtime";
recommendedSetup();
await Promise.all([import("./routes/Home"), import("./routes/Checkout")]);
markRetryHealthyBoot();
Disable auto healthy boot:
recommendedSetup({ healthyBoot: "manual" });
Disable version checking:
const cleanup = recommendedSetup({ versionCheck: false });
Retry behavior and event flow
spa-guard uses a single retry orchestrator (retryOrchestrator.ts) as the sole owner of retry lifecycle. All reload scheduling, deduplication, and fallback transitions run through triggerRetry().
Retry state machine
The orchestrator maintains an explicit phase:
idle— no retry in progressscheduled— a reload has been scheduled (timer running); concurrent triggers are deduplicatedfallback— retries exhausted, fallback UI is shown; further triggers are ignored
Retry progression
When a recoverable error occurs (chunk load error, unhandled rejection, vite:preloadError, or static asset 404):
- Event listener classifies the event and calls
triggerRetry({ source, error }). - Orchestrator checks phase — if already
scheduledorfallback, returns immediately without scheduling another reload. - On first trigger: reads
RETRY_ATTEMPT_PARAMfrom URL to restore attempt count after a reload. - If attempts remain: increments attempt, calls
showLoadingUI(nextAttempt)to render the loading UI immediately (before the timer fires), sets a timer forreloadDelays[currentAttempt], encodesretryIdand attempt count into the reload URL, then navigates. Requiresoptions.html.loading.contentto be configured; if absent,showLoadingUIreturns silently and the retry still proceeds. - If attempts are exhausted: transitions to
fallback, callssetFallbackMode(), sends a beacon, and renders fallback UI.
URL params
The orchestrator serializes state into URL params for cross-reload continuity:
RETRY_ATTEMPT_PARAM— current attempt count (strict non-negative integer; malformed values are ignored)RETRY_ID_PARAM— unique ID for this retry sessionCACHE_BUST_PARAM— timestamp for cache busting (set whencacheBust: trueis passed, e.g. for static asset errors)
Loading UI during retry delay
When options.html.loading.content is configured, showLoadingUI(attempt) is called before the reload timer fires. It injects the loading HTML into the target element (selector from options.html.fallback.selector, defaulting to body), reveals the data-spa-guard-section="retrying" element, fills attempt numbers into [data-spa-guard-content="attempt"] elements, applies i18n via applyI18n/getI18n, and optionally hides or replaces the spinner based on options.html.spinner. If loading content is not configured or the target element is not found, showLoadingUI returns silently — the retry still proceeds normally.
Default fallback/loading templates are theme-aware: they set color-scheme: light dark and apply neutral dark colors automatically via @media (prefers-color-scheme: dark).
Unhandledrejection serialization
serializeError handles PromiseRejectionEvent with strict redaction guardrails. For HTTP-like errors it extracts only safe metadata: status, statusText, url, method, response.type, and X-Request-ID from response headers when present. Response body, request/response payload, and the full headers object are never included in serialized output. For request wrappers (reason.request / reason.config) only method, url, and baseURL are extracted. Deep object traversal is bounded by MAX_DEPTH=4, MAX_KEYS=20, and MAX_STRING_LEN=500 to prevent oversized beacons. Circular references are handled via a WeakSet visited tracker. The output also includes isTrusted, timeStamp from the event, and runtime context (pageUrl, constructorName).
This HTTP-like extraction also applies to Error subclasses that carry a response field (for example ResponseError), not only to plain objects.
Retry reset
If enough time has passed since the last reload (configurable via minTimeBetweenResets, default 5000 ms), the orchestrator resets the attempt counter and starts a fresh retry cycle instead of continuing to fallback. This prevents stale URL params from triggering fallback on a clean page load.
Healthy boot
After a successful app boot following a retry reload, markRetryHealthyBoot() clears retry URL params, cancels any pending timer, and resets orchestrator state.
Default behavior in recommendedSetup: auto healthy-boot after a grace period (healthyBoot: "auto").
Manual override: set healthyBoot: "manual" and call markRetryHealthyBoot() yourself once critical boot is confirmed.
Retry on unhandled rejections
By default, regular unhandledrejection events only send a beacon (handleUnhandledRejections.retry: false). To also trigger triggerRetry() on non-chunk rejections, enable it explicitly — but only if you're confident these rejections indicate stale chunks:
window.__SPA_GUARD_OPTIONS__ = {
handleUnhandledRejections: {
retry: true,
},
};
Note: enabling retry for generic rejections can cause reload loops if your app has non-chunk unhandled rejections unrelated to stale assets.
Resource failures produce resource-error beacons for code resources by default: scripts,
stylesheets, module preloads, and code-bearing preloads or prefetches. Font, image, icon, and other
non-code failures are excluded. Worker and document preload destinations count as code. When a
link's kind is not recognized, its URL extension is used (.js, .mjs, .cjs, and .css are
treated as code). Set resources.report to "all" to report every resource element or "none" to
disable resource beacons.
Beacon deduplication and a 20-report per-page cap are enabled by default. dedupeWindowMs controls
how long a report with the same stable identifying fields is suppressed; the default is five
minutes. Serialized diagnostics are not part of the dedupe key because they can contain volatile
event data such as timestamps. The retained dedupe keys are bounded. Set dedupe to false to
disable deduplication. maxPerPageLoad accepts non-negative values; 0 or Infinity disables the
cap, while a negative value uses the default safety cap of 20.
When the cap is reached, one beacon-throttled notice is sent without a stale interim total.
A final report with the total suppressed during the page load is attempted on pagehide, with
visibilitychange as a fallback. Exhausted-retry reports bypass this cap.
Known browser noise
Known browser-noise filtering is enabled by default. Set errors.ignoreKnownNoise to false to
disable it. User-provided errors.ignore patterns are added to the built-in list rather than
replacing it. The list is exported as KNOWN_BROWSER_NOISE.
It contains only the ResizeObserver loop messages and non-error rejection noise. Cross-origin
"Script error." messages are intentionally not in that default list; add the exported
CROSS_ORIGIN_ERROR pattern to errors.ignore only when that signal is unwanted.
Retry source and resource status
The exhausted-retry beacon retains the historical eventName: "chunk_error_max_reloads".
Its errorType identifies the retry source, and the same value is included in serialized.
Resource failure beacons populate httpStatus from Resource Timing when the browser provides it.
The browser-reported value can be 0 when an HTTP status is unavailable or opaque, so it should
not be interpreted as a specific HTTP response. When the API or status field is unavailable,
httpStatus is omitted; the context records a no-byte signal when that signal is available.
Before transport, all beacon strings are capped to the limits enforced by parseBeacon, and
non-finite numeric fields are omitted. This prevents an otherwise valid report from being rejected
as a whole because one client-derived field is too long or invalid.
Static asset burst coalescing
Multiple 404'd asset errors within a short window (default 500 ms) are coalesced into a single triggerRetry({ cacheBust: true, source: "static-asset-error" }). The orchestrator's deduplication ensures only one reload is scheduled.
Retry ownership rule
Only retryOrchestrator.ts may schedule reloads, advance retry state, or transition to fallback. Listeners, renderers, and other modules must not schedule their own retry timers or set fallback state directly.
API
@ovineko/spa-guard (common)
listen— subscribe to spa-guard eventsevents— event type constantsoptions— runtime options helpersdisableDefaultRetry/enableDefaultRetry/isDefaultRetryEnabled— control default retry behaviourisInFallbackMode— returnstruewhen fallback UI is active (retries exhausted)resetFallbackMode— clears the fallback flag; use in tests or programmatic recovery flowsBeaconError— error class for beacon failuresForceRetryError— error class to force a retrystripForceRetryMagic(message)— remove the internal force-retry marker before rendering or persisting an error messageisCodeResource(target)— classify a script or link element forresources.report: "code"resetBeaconThrottle()— clear dedupe/cap state and lifecycle listeners; intended for tests or an explicit host-managed page lifecycleKNOWN_BROWSER_NOISE/CROSS_ORIGIN_ERROR— reusable error-message filter patterns
Retry orchestrator (single owner of retry lifecycle):
triggerRetry(input?)— trigger a retry from any source; returnsTriggerResult:{ status: "accepted" }— reload scheduled{ status: "deduped", reason: string }— ignored because another retry is already scheduled{ status: "fallback" }— already in fallback mode; no retry scheduled{ status: "internal-error" }— retry setup failed and the orchestrator returned toidle{ status: "retry-disabled" }— retry is disabled viadisableDefaultRetry()
markRetryHealthyBoot()— call after a successful boot following a retry reload; clears URL params, cancels timers, resets orchestrator state and fallback flaggetRetrySnapshot()— returns current orchestrator state:{ phase, attempt, retryId, lastSource, lastTriggerTime }
@ovineko/spa-guard/runtime
recommendedSetup(options?)— enable recommended runtime features; idempotent and returns cleanup functionRecommendedSetupOptions—{ versionCheck?: boolean; healthyBoot?: "auto" | "manual" | "off" | false | { mode?: "auto"; graceMs?: number } }healthyBoot.graceMsacts as a lower bound override for auto mode (max(computedGraceMs, graceMs))
startVersionCheck/stopVersionCheck— manual version check controlgetState/subscribeToState— runtime state accessSpaGuardState— state typeshowSpinner/dismissSpinner/getSpinnerHtml— spinner helperssetTranslations— override i18n stringsForceRetryError
@ovineko/spa-guard/schema
Schemas for spa-guard configuration.
@ovineko/spa-guard/i18n
Built-in translation strings.
@ovineko/spa-guard/runtime/debug
Debug helpers for testing error scenarios. Use createDebugger() to mount an in-page panel with buttons for each scenario.
Available error scenarios:
dispatchChunkLoadError— unhandled ChunkLoadError rejectiondispatchNetworkTimeout— unhandled network timeout after a delaydispatchSyncRuntimeError— sync error thrown during React renderdispatchAsyncRuntimeError— uncaught error via setTimeoutdispatchFinallyError— unhandled rejection from Promise.finallydispatchForceRetryError— ForceRetryError to exercise the force-retry pathdispatchUnhandledRejection— generic unhandled promise rejectiondispatchRetryExhausted— emits exhaustion and uses the debug transition to render fallback UI with orchestrator phasefallbackdispatchStaticAsset404— appends a hashed<script>that 404s, exercising the Resource Timing API-based static asset detection path
Related packages
- @ovineko/spa-guard-react —
lazyWithRetryand React error boundary - @ovineko/spa-guard-react-router — React Router error boundary
- @ovineko/spa-guard-vite — Vite plugin (injects runtime script)
- @ovineko/spa-guard-node — Node.js cache and builder API
- @ovineko/spa-guard-fastify — Fastify plugin
- @ovineko/spa-guard-eslint — ESLint rules