@ovineko/spa-guard 0.0.1-alpha-28 → 0.0.1-alpha-30

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,52 @@ 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, sets a timer for `reloadDelays[currentAttempt]`, encodes `retryId` and attempt count into the reload URL, then navigates.
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
+ ### Retry reset
62
+
63
+ 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.
64
+
65
+ ### Healthy boot
66
+
67
+ After a successful app boot following a retry reload, call `markRetryHealthyBoot()`. This clears retry URL params, cancels any pending timer, and resets orchestrator state.
68
+
69
+ ### Static asset burst coalescing
70
+
71
+ 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.
72
+
73
+ ### Retry ownership rule
74
+
75
+ 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.
76
+
31
77
  ## API
32
78
 
33
79
  ### `@ovineko/spa-guard` (common)
@@ -36,9 +82,22 @@ const cleanup = recommendedSetup({ versionCheck: false });
36
82
  - `events` — event type constants
37
83
  - `options` — runtime options helpers
38
84
  - `disableDefaultRetry` / `enableDefaultRetry` / `isDefaultRetryEnabled` — control default retry behaviour
85
+ - `isInFallbackMode` — returns `true` when fallback UI is active (retries exhausted)
86
+ - `resetFallbackMode` — clears the fallback flag; use in tests or programmatic recovery flows
39
87
  - `BeaconError` — error class for beacon failures
40
88
  - `ForceRetryError` — error class to force a retry
41
89
 
90
+ **Retry orchestrator (single owner of retry lifecycle):**
91
+
92
+ - `triggerRetry(input?)` — trigger a retry from any source; returns `TriggerResult`:
93
+ - `{ status: "accepted" }` — reload scheduled
94
+ - `{ status: "deduped", reason: string }` — ignored because another retry is already scheduled or an internal error occurred
95
+ - `{ status: "fallback" }` — already in fallback mode; no retry scheduled
96
+ - `{ status: "retry-disabled" }` — retry is disabled via `disableDefaultRetry()`
97
+ - `markRetryHealthyBoot()` — call after a successful boot following a retry reload; clears URL params, cancels timers, resets orchestrator state and fallback flag
98
+ - `getRetrySnapshot()` — returns current orchestrator state: `{ phase, attempt, retryId, lastSource, lastTriggerTime }`
99
+ - `resetRetryOrchestratorForTests()` — resets all orchestrator state including fallback flag; use in test teardown
100
+
42
101
  ### `@ovineko/spa-guard/runtime`
43
102
 
44
103
  - `recommendedSetup(options?)` — enable recommended runtime features; returns cleanup function
@@ -60,7 +119,19 @@ Built-in translation strings.
60
119
 
61
120
  ### `@ovineko/spa-guard/runtime/debug`
62
121
 
63
- Debug helpers for testing error scenarios.
122
+ Debug helpers for testing error scenarios. Use `createDebugger()` to mount an in-page panel with buttons for each scenario.
123
+
124
+ Available error scenarios:
125
+
126
+ - `dispatchChunkLoadError` — unhandled ChunkLoadError rejection
127
+ - `dispatchNetworkTimeout` — unhandled network timeout after a delay
128
+ - `dispatchSyncRuntimeError` — sync error thrown during React render
129
+ - `dispatchAsyncRuntimeError` — uncaught error via setTimeout
130
+ - `dispatchFinallyError` — unhandled rejection from Promise.finally
131
+ - `dispatchForceRetryError` — ForceRetryError to exercise the force-retry path
132
+ - `dispatchUnhandledRejection` — generic unhandled promise rejection
133
+ - `dispatchRetryExhausted` — renders fallback UI directly (bypasses orchestrator; orchestrator phase remains `idle`)
134
+ - `dispatchStaticAsset404` — appends a hashed `<script>` that 404s, exercising the Resource Timing API-based static asset detection path
64
135
 
65
136
  ## Related packages
66
137
 
@@ -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-5ANASVKX.js";
7
1
  import {
8
2
  SPINNER_ID,
9
3
  defaultSpinnerSvg,
10
4
  extractVersionFromHtml,
11
5
  sanitizeCssValue
12
- } from "./chunk-BA5VUNSU.js";
6
+ } from "./chunk-6TP2S7L6.js";
7
+ import {
8
+ createLogger,
9
+ isChunkError,
10
+ listenInternal,
11
+ serializeError
12
+ } from "./chunk-DINLBER6.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-62AC5565.js";
22
- import {
23
- attemptReload,
24
- sendBeacon,
25
- shouldForceRetry,
26
- shouldIgnoreMessages
27
- } from "./chunk-4EXCHJBE.js";
21
+ } from "./chunk-A5HF6LY6.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-PE5QPP5Y.js";
37
+ resetRetryOrchestratorForTests,
38
+ sendBeacon,
39
+ shouldForceRetry,
40
+ shouldIgnoreMessages,
41
+ subscribe,
42
+ triggerRetry
43
+ } from "./chunk-XRBUBTPZ.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
  };
@@ -2,7 +2,7 @@ import {
2
2
  defaultSpinnerHtml,
3
3
  getOptions,
4
4
  spinnerStateWindowKey
5
- } from "./chunk-PE5QPP5Y.js";
5
+ } from "./chunk-XRBUBTPZ.js";
6
6
 
7
7
  // src/common/parseVersion.ts
8
8
  function extractVersionFromHtml(html) {
@@ -1,12 +1,10 @@
1
- import {
2
- showFallbackUI
3
- } from "./chunk-4EXCHJBE.js";
4
1
  import {
5
2
  ForceRetryError,
6
3
  debugSyncErrorEventType,
7
4
  emitEvent,
8
- getOptions
9
- } from "./chunk-PE5QPP5Y.js";
5
+ getOptions,
6
+ setFallbackStateForDebug
7
+ } from "./chunk-XRBUBTPZ.js";
10
8
 
11
9
  // src/runtime/debug/errorDispatchers.ts
12
10
  function dispatchAsyncRuntimeError() {
@@ -36,18 +34,21 @@ function dispatchNetworkTimeout(delayMs = 3e3) {
36
34
  }
37
35
  function dispatchRetryExhausted() {
38
36
  const options = getOptions();
39
- const reloadDelays = options.reloadDelays ?? [1e3, 2e3, 5e3];
37
+ const reloadDelays = options.reloadDelays ?? [];
40
38
  emitEvent({
41
39
  finalAttempt: reloadDelays.length,
42
40
  name: "retry-exhausted",
43
41
  retryId: ""
44
42
  });
45
- showFallbackUI();
43
+ setFallbackStateForDebug();
46
44
  }
47
45
  function dispatchStaticAsset404() {
48
- const hash = crypto.randomUUID().replaceAll("-", "").slice(0, 8);
46
+ const hash = crypto.randomUUID().slice(0, 8);
49
47
  const script = document.createElement("script");
50
48
  script.src = `/assets/index-${hash}.js`;
49
+ script.addEventListener("error", () => {
50
+ script.remove();
51
+ });
51
52
  document.head.append(script);
52
53
  }
53
54
  function dispatchSyncRuntimeError() {
@@ -1,22 +1,17 @@
1
1
  import {
2
- attemptReload,
3
- sendBeacon,
4
- shouldForceRetry,
5
- shouldIgnoreMessages
6
- } from "./chunk-4EXCHJBE.js";
7
- import {
8
- clearRetryStateFromUrl,
9
2
  emitEvent,
10
3
  getLogger,
11
4
  getOptions,
12
5
  getRetryInfoForBeacon,
13
- getRetryStateFromUrl,
14
6
  isInitialized,
15
7
  markInitialized,
8
+ sendBeacon,
16
9
  setLogger,
10
+ shouldForceRetry,
11
+ shouldIgnoreMessages,
17
12
  staticAssetRecoveryKey,
18
- updateRetryStateInUrl
19
- } from "./chunk-PE5QPP5Y.js";
13
+ triggerRetry
14
+ } from "./chunk-XRBUBTPZ.js";
20
15
 
21
16
  // src/common/isChunkError.ts
22
17
  var isChunkError = (error) => {
@@ -182,6 +177,9 @@ var isStaticAssetError = (event) => {
182
177
  return false;
183
178
  };
184
179
  var checkResourceStatus = (url) => {
180
+ if (typeof performance === "undefined" || typeof performance.getEntriesByName !== "function") {
181
+ return true;
182
+ }
185
183
  const entries = performance.getEntriesByName(url, "resource");
186
184
  if (entries.length === 0) {
187
185
  return true;
@@ -195,11 +193,12 @@ var checkResourceStatus = (url) => {
195
193
  }
196
194
  return false;
197
195
  };
198
- var isLikely404 = (url, timeSinceNavMs = typeof performance === "undefined" ? 0 : performance.now()) => {
196
+ var isLikely404 = (url, timeSinceNavMs) => {
199
197
  if (url !== void 0) {
200
198
  return checkResourceStatus(url);
201
199
  }
202
- return timeSinceNavMs > 3e4;
200
+ const elapsed = timeSinceNavMs ?? (typeof performance === "undefined" ? 0 : performance.now());
201
+ return elapsed > 3e4;
203
202
  };
204
203
  var getAssetUrl = (event) => {
205
204
  const target = event.target;
@@ -226,6 +225,9 @@ var getState = () => {
226
225
  return globalThis.window[staticAssetRecoveryKey];
227
226
  };
228
227
  var handleStaticAssetFailure = (url) => {
228
+ if (globalThis.window === void 0) {
229
+ return;
230
+ }
229
231
  const state = getState();
230
232
  state.failedAssets.add(url);
231
233
  if (state.recoveryTimer !== null) {
@@ -235,11 +237,11 @@ var handleStaticAssetFailure = (url) => {
235
237
  const delay = options.staticAssets?.recoveryDelay ?? 500;
236
238
  state.recoveryTimer = setTimeout(() => {
237
239
  const s = getState();
240
+ s.recoveryTimer = null;
238
241
  const assets = [...s.failedAssets];
239
242
  s.failedAssets = /* @__PURE__ */ new Set();
240
- s.recoveryTimer = null;
241
243
  const error = new Error(`Static asset load failed: ${assets.join(", ")}`);
242
- attemptReload(error, { cacheBust: true });
244
+ triggerRetry({ cacheBust: true, error, source: "static-asset-error" });
243
245
  }, delay);
244
246
  };
245
247
 
@@ -253,24 +255,6 @@ var listenInternal = (serializeError2, logger) => {
253
255
  }
254
256
  markInitialized();
255
257
  const options = getOptions();
256
- const reloadDelays = options.reloadDelays ?? [];
257
- const retryState = getRetryStateFromUrl();
258
- if (retryState && retryState.retryAttempt >= reloadDelays.length) {
259
- getLogger()?.retryLimitExceeded(retryState.retryAttempt, reloadDelays.length);
260
- updateRetryStateInUrl(retryState.retryId, -1);
261
- }
262
- if (retryState && (retryState.retryAttempt >= reloadDelays.length || retryState.retryAttempt === -1)) {
263
- globalThis.window.addEventListener(
264
- "load",
265
- () => {
266
- const current = getRetryStateFromUrl();
267
- if (current?.retryAttempt === -1) {
268
- clearRetryStateFromUrl();
269
- }
270
- },
271
- { once: true }
272
- );
273
- }
274
258
  const wa = globalThis.window.addEventListener.bind(globalThis.window);
275
259
  wa(
276
260
  "error",
@@ -293,12 +277,12 @@ var listenInternal = (serializeError2, logger) => {
293
277
  getLogger()?.capturedError("error", event);
294
278
  if (isChunkError(event)) {
295
279
  event.preventDefault();
296
- attemptReload(event.error ?? event);
280
+ triggerRetry({ error: event.error ?? event, source: "chunk-error" });
297
281
  return;
298
282
  }
299
283
  if (shouldForceRetry([event.message])) {
300
284
  event.preventDefault();
301
- attemptReload(event.error ?? event);
285
+ triggerRetry({ error: event.error ?? event, source: "force-retry" });
302
286
  return;
303
287
  }
304
288
  const serialized = serializeError2(event);
@@ -319,12 +303,12 @@ var listenInternal = (serializeError2, logger) => {
319
303
  getLogger()?.capturedError("unhandledrejection", event);
320
304
  if (isChunkError(event.reason)) {
321
305
  event.preventDefault();
322
- attemptReload(event.reason);
306
+ triggerRetry({ error: event.reason, source: "chunk-error" });
323
307
  return;
324
308
  }
325
309
  if (shouldForceRetry([errorMessage])) {
326
310
  event.preventDefault();
327
- attemptReload(event.reason);
311
+ triggerRetry({ error: event.reason, source: "force-retry" });
328
312
  return;
329
313
  }
330
314
  const rejectionConfig = options.handleUnhandledRejections;
@@ -339,7 +323,7 @@ var listenInternal = (serializeError2, logger) => {
339
323
  }
340
324
  if (rejectionConfig?.retry !== false) {
341
325
  event.preventDefault();
342
- attemptReload(event.reason);
326
+ triggerRetry({ error: event.reason, source: "unhandled-rejection" });
343
327
  }
344
328
  });
345
329
  wa("securitypolicyviolation", (event) => {
@@ -357,13 +341,14 @@ var listenInternal = (serializeError2, logger) => {
357
341
  });
358
342
  });
359
343
  wa("vite:preloadError", (event) => {
360
- const errorMsg = event?.payload?.message || event?.message;
344
+ const payload = event?.payload;
345
+ const errorMsg = payload?.message || event?.message;
361
346
  if (shouldIgnoreMessages([errorMsg])) {
362
347
  return;
363
348
  }
364
349
  getLogger()?.capturedError("vite:preloadError", event);
365
350
  event.preventDefault();
366
- attemptReload(event?.payload ?? event);
351
+ triggerRetry({ error: payload ?? event, source: "vite:preloadError" });
367
352
  });
368
353
  };
369
354
 
@@ -371,6 +356,7 @@ var listenInternal = (serializeError2, logger) => {
371
356
  var PREFIX = "[spa-guard]";
372
357
  var eventLogConfig = {
373
358
  "chunk-error": "error",
359
+ "fallback-ui-not-rendered": "error",
374
360
  "fallback-ui-shown": "warn",
375
361
  "lazy-retry-attempt": "warn",
376
362
  "lazy-retry-exhausted": "error",
@@ -386,6 +372,10 @@ var formatEvent = (event) => {
386
372
  case "chunk-error": {
387
373
  return `${PREFIX} chunk-error: isRetrying=${event.isRetrying}`;
388
374
  }
375
+ case "fallback-ui-not-rendered": {
376
+ const selectorPart = event.selector ? ` selector=${event.selector}` : "";
377
+ return `${PREFIX} fallback-ui-not-rendered: reason=${event.reason}${selectorPart}`;
378
+ }
389
379
  case "fallback-ui-shown": {
390
380
  return `${PREFIX} fallback-ui-shown`;
391
381
  }
@@ -3,7 +3,7 @@ import {
3
3
  getRetryAttemptFromUrl,
4
4
  getRetryStateFromUrl,
5
5
  subscribe
6
- } from "./chunk-PE5QPP5Y.js";
6
+ } from "./chunk-XRBUBTPZ.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) {