@ovineko/spa-guard 0.0.1-alpha-29 → 0.0.1-alpha-31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,60 @@ Disable version checking:
28
28
  const cleanup = recommendedSetup({ versionCheck: false });
29
29
  ```
30
30
 
31
+ ## Retry behavior and event flow
32
+
33
+ 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()`.
34
+
35
+ ### Retry state machine
36
+
37
+ The orchestrator maintains an explicit phase:
38
+
39
+ - `idle` — no retry in progress
40
+ - `scheduled` — a reload has been scheduled (timer running); concurrent triggers are deduplicated
41
+ - `fallback` — retries exhausted, fallback UI is shown; further triggers are ignored
42
+
43
+ ### Retry progression
44
+
45
+ When a recoverable error occurs (chunk load error, unhandled rejection, `vite:preloadError`, or static asset 404):
46
+
47
+ 1. Event listener classifies the event and calls `triggerRetry({ source, error })`.
48
+ 2. Orchestrator checks phase — if already `scheduled` or `fallback`, returns immediately without scheduling another reload.
49
+ 3. On first trigger: reads `RETRY_ATTEMPT_PARAM` from URL to restore attempt count after a reload.
50
+ 4. If attempts remain: increments attempt, calls `showLoadingUI(nextAttempt)` to render the loading UI immediately (before the timer fires), sets a timer for `reloadDelays[currentAttempt]`, encodes `retryId` and attempt count into the reload URL, then navigates. Requires `options.html.loading.content` to be configured; if absent, `showLoadingUI` returns silently and the retry still proceeds.
51
+ 5. If attempts are exhausted: transitions to `fallback`, calls `setFallbackMode()`, sends a beacon, and renders fallback UI.
52
+
53
+ ### URL params
54
+
55
+ The orchestrator serializes state into URL params for cross-reload continuity:
56
+
57
+ - `RETRY_ATTEMPT_PARAM` — current attempt count (strict non-negative integer; malformed values are ignored)
58
+ - `RETRY_ID_PARAM` — unique ID for this retry session
59
+ - `CACHE_BUST_PARAM` — timestamp for cache busting (set when `cacheBust: true` is passed, e.g. for static asset errors)
60
+
61
+ ### Loading UI during retry delay
62
+
63
+ 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.
64
+
65
+ ### Unhandledrejection serialization
66
+
67
+ `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`).
68
+
69
+ ### Retry reset
70
+
71
+ 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.
72
+
73
+ ### Healthy boot
74
+
75
+ After a successful app boot following a retry reload, call `markRetryHealthyBoot()`. This clears retry URL params, cancels any pending timer, and resets orchestrator state.
76
+
77
+ ### Static asset burst coalescing
78
+
79
+ 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.
80
+
81
+ ### Retry ownership rule
82
+
83
+ 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.
84
+
31
85
  ## API
32
86
 
33
87
  ### `@ovineko/spa-guard` (common)
@@ -41,6 +95,17 @@ const cleanup = recommendedSetup({ versionCheck: false });
41
95
  - `BeaconError` — error class for beacon failures
42
96
  - `ForceRetryError` — error class to force a retry
43
97
 
98
+ **Retry orchestrator (single owner of retry lifecycle):**
99
+
100
+ - `triggerRetry(input?)` — trigger a retry from any source; returns `TriggerResult`:
101
+ - `{ status: "accepted" }` — reload scheduled
102
+ - `{ status: "deduped", reason: string }` — ignored because another retry is already scheduled or an internal error occurred
103
+ - `{ status: "fallback" }` — already in fallback mode; no retry scheduled
104
+ - `{ status: "retry-disabled" }` — retry is disabled via `disableDefaultRetry()`
105
+ - `markRetryHealthyBoot()` — call after a successful boot following a retry reload; clears URL params, cancels timers, resets orchestrator state and fallback flag
106
+ - `getRetrySnapshot()` — returns current orchestrator state: `{ phase, attempt, retryId, lastSource, lastTriggerTime }`
107
+ - `resetRetryOrchestratorForTests()` — resets all orchestrator state including fallback flag; use in test teardown
108
+
44
109
  ### `@ovineko/spa-guard/runtime`
45
110
 
46
111
  - `recommendedSetup(options?)` — enable recommended runtime features; returns cleanup function
@@ -73,7 +138,7 @@ Available error scenarios:
73
138
  - `dispatchFinallyError` — unhandled rejection from Promise.finally
74
139
  - `dispatchForceRetryError` — ForceRetryError to exercise the force-retry path
75
140
  - `dispatchUnhandledRejection` — generic unhandled promise rejection
76
- - `dispatchRetryExhausted` — simulates retry-exhausted state and renders fallback UI
141
+ - `dispatchRetryExhausted` — renders fallback UI directly (bypasses orchestrator; orchestrator phase remains `idle`)
77
142
  - `dispatchStaticAsset404` — appends a hashed `<script>` that 404s, exercising the Resource Timing API-based static asset detection path
78
143
 
79
144
  ## Related packages
@@ -12,8 +12,9 @@ export { createLogger } from "./common/logger";
12
12
  export { getOptions, optionsWindowKey } from "./common/options";
13
13
  export type { Options } from "./common/options";
14
14
  export { extractVersionFromHtml } from "./common/parseVersion";
15
- export { attemptReload } from "./common/reload";
16
15
  export { retryImport } from "./common/retryImport";
16
+ export { getRetrySnapshot, markRetryHealthyBoot, resetRetryOrchestratorForTests, triggerRetry, } from "./common/retryOrchestrator";
17
+ export type { RetryPhase, RetrySnapshot, TriggerInput, TriggerResult, } from "./common/retryOrchestrator";
17
18
  export { serializeError } from "./common/serializeError";
18
19
  export { defaultSpinnerSvg, sanitizeCssValue, SPINNER_ID } from "./common/spinner";
19
20
  export { dispatchAsyncRuntimeError, dispatchChunkLoadError, dispatchFinallyError, dispatchForceRetryError, dispatchNetworkTimeout, dispatchSyncRuntimeError, dispatchUnhandledRejection, } from "./runtime/debug/errorDispatchers";
package/dist/_internal.js CHANGED
@@ -1,15 +1,15 @@
1
- import {
2
- createLogger,
3
- isChunkError,
4
- listenInternal,
5
- serializeError
6
- } from "./chunk-CBAJOLBG.js";
7
1
  import {
8
2
  SPINNER_ID,
9
3
  defaultSpinnerSvg,
10
4
  extractVersionFromHtml,
11
5
  sanitizeCssValue
12
- } from "./chunk-5546JVQV.js";
6
+ } from "./chunk-QDYSBRW2.js";
7
+ import {
8
+ createLogger,
9
+ isChunkError,
10
+ listenInternal,
11
+ serializeError
12
+ } from "./chunk-YTH35AWC.js";
13
13
  import {
14
14
  dispatchAsyncRuntimeError,
15
15
  dispatchChunkLoadError,
@@ -18,13 +18,7 @@ import {
18
18
  dispatchNetworkTimeout,
19
19
  dispatchSyncRuntimeError,
20
20
  dispatchUnhandledRejection
21
- } from "./chunk-3LAQJKWC.js";
22
- import {
23
- attemptReload,
24
- sendBeacon,
25
- shouldForceRetry,
26
- shouldIgnoreMessages
27
- } from "./chunk-2BUEJXDB.js";
21
+ } from "./chunk-ALPGMLNW.js";
28
22
  import {
29
23
  applyI18n,
30
24
  debugSyncErrorEventType,
@@ -36,10 +30,17 @@ import {
36
30
  getI18n,
37
31
  getOptions,
38
32
  getRetryInfoForBeacon,
33
+ getRetrySnapshot,
39
34
  isDefaultRetryEnabled,
35
+ markRetryHealthyBoot,
40
36
  optionsWindowKey,
41
- subscribe
42
- } from "./chunk-LBHLKYQS.js";
37
+ resetRetryOrchestratorForTests,
38
+ sendBeacon,
39
+ shouldForceRetry,
40
+ shouldIgnoreMessages,
41
+ subscribe,
42
+ triggerRetry
43
+ } from "./chunk-X6IW7S2K.js";
43
44
  import "./chunk-MLKGABMK.js";
44
45
 
45
46
  // src/common/handleErrorWithSpaGuard.ts
@@ -62,7 +63,7 @@ var handleErrorWithSpaGuard = (error, options) => {
62
63
  const isChunk = isChunkError(error);
63
64
  const isForceRetry = shouldForceRetry([errorMessage]);
64
65
  if ((isChunk || isForceRetry) && autoRetryChunkErrors) {
65
- attemptReload(error);
66
+ triggerRetry({ error, source: "error-boundary" });
66
67
  } else if (sendBeaconOnError) {
67
68
  sendBeacon({
68
69
  errorMessage: error instanceof Error ? error.message : String(error),
@@ -130,14 +131,13 @@ var retryImport = async (importFn, delays, options) => {
130
131
  const willReload = callReloadOnFailure === true && isChunkError(lastError) && isDefaultRetryEnabled();
131
132
  emitEvent({ error: lastError, name: "lazy-retry-exhausted", totalAttempts, willReload });
132
133
  if (willReload) {
133
- attemptReload(lastError);
134
+ triggerRetry({ error: lastError, source: "lazy-import-failure" });
134
135
  }
135
136
  throw lastError;
136
137
  };
137
138
  export {
138
139
  SPINNER_ID,
139
140
  applyI18n,
140
- attemptReload,
141
141
  createLogger,
142
142
  debugSyncErrorEventType,
143
143
  defaultErrorFallbackHtml,
@@ -156,14 +156,18 @@ export {
156
156
  extractVersionFromHtml,
157
157
  getI18n,
158
158
  getOptions,
159
+ getRetrySnapshot,
159
160
  handleErrorWithSpaGuard,
160
161
  isChunkError,
161
162
  isDefaultRetryEnabled,
162
163
  listenInternal,
163
164
  logMessage,
165
+ markRetryHealthyBoot,
164
166
  optionsWindowKey,
167
+ resetRetryOrchestratorForTests,
165
168
  retryImport,
166
169
  sanitizeCssValue,
167
170
  serializeError,
168
- subscribe
171
+ subscribe,
172
+ triggerRetry
169
173
  };
@@ -1,12 +1,10 @@
1
- import {
2
- showFallbackUI
3
- } from "./chunk-2BUEJXDB.js";
4
1
  import {
5
2
  ForceRetryError,
6
3
  debugSyncErrorEventType,
7
4
  emitEvent,
8
- getOptions
9
- } from "./chunk-LBHLKYQS.js";
5
+ getOptions,
6
+ setFallbackStateForDebug
7
+ } from "./chunk-X6IW7S2K.js";
10
8
 
11
9
  // src/runtime/debug/errorDispatchers.ts
12
10
  function dispatchAsyncRuntimeError() {
@@ -42,7 +40,7 @@ function dispatchRetryExhausted() {
42
40
  name: "retry-exhausted",
43
41
  retryId: ""
44
42
  });
45
- showFallbackUI();
43
+ setFallbackStateForDebug();
46
44
  }
47
45
  function dispatchStaticAsset404() {
48
46
  const hash = crypto.randomUUID().slice(0, 8);
@@ -2,7 +2,7 @@ import {
2
2
  defaultSpinnerHtml,
3
3
  getOptions,
4
4
  spinnerStateWindowKey
5
- } from "./chunk-LBHLKYQS.js";
5
+ } from "./chunk-X6IW7S2K.js";
6
6
 
7
7
  // src/common/parseVersion.ts
8
8
  function extractVersionFromHtml(html) {
@@ -3,7 +3,7 @@ import {
3
3
  getRetryAttemptFromUrl,
4
4
  getRetryStateFromUrl,
5
5
  subscribe
6
- } from "./chunk-LBHLKYQS.js";
6
+ } from "./chunk-X6IW7S2K.js";
7
7
 
8
8
  // src/runtime/state.ts
9
9
  var getInitialStateFromUrl = () => {
@@ -37,15 +37,6 @@ var getInitialStateFromUrl = () => {
37
37
  lastRetryResetTime: resetInfo?.timestamp
38
38
  };
39
39
  }
40
- if (retryState.retryAttempt === -1) {
41
- return {
42
- currentAttempt: 0,
43
- isFallbackShown: true,
44
- isWaiting: false,
45
- lastResetRetryId: resetInfo?.previousRetryId,
46
- lastRetryResetTime: resetInfo?.timestamp
47
- };
48
- }
49
40
  return {
50
41
  currentAttempt: retryState.retryAttempt,
51
42
  isFallbackShown: false,
@@ -58,7 +49,12 @@ var currentState = getInitialStateFromUrl();
58
49
  var stateSubscribers = /* @__PURE__ */ new Set();
59
50
  var updateState = (nextState) => {
60
51
  currentState = nextState;
61
- stateSubscribers.forEach((cb) => cb(currentState));
52
+ stateSubscribers.forEach((cb) => {
53
+ try {
54
+ cb(currentState);
55
+ } catch {
56
+ }
57
+ });
62
58
  };
63
59
  subscribe((event) => {
64
60
  switch (event.name) {