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

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
@@ -36,6 +36,8 @@ const cleanup = recommendedSetup({ versionCheck: false });
36
36
  - `events` — event type constants
37
37
  - `options` — runtime options helpers
38
38
  - `disableDefaultRetry` / `enableDefaultRetry` / `isDefaultRetryEnabled` — control default retry behaviour
39
+ - `isInFallbackMode` — returns `true` when fallback UI is active (retries exhausted)
40
+ - `resetFallbackMode` — clears the fallback flag; use in tests or programmatic recovery flows
39
41
  - `BeaconError` — error class for beacon failures
40
42
  - `ForceRetryError` — error class to force a retry
41
43
 
@@ -60,7 +62,19 @@ Built-in translation strings.
60
62
 
61
63
  ### `@ovineko/spa-guard/runtime/debug`
62
64
 
63
- Debug helpers for testing error scenarios.
65
+ Debug helpers for testing error scenarios. Use `createDebugger()` to mount an in-page panel with buttons for each scenario.
66
+
67
+ Available error scenarios:
68
+
69
+ - `dispatchChunkLoadError` — unhandled ChunkLoadError rejection
70
+ - `dispatchNetworkTimeout` — unhandled network timeout after a delay
71
+ - `dispatchSyncRuntimeError` — sync error thrown during React render
72
+ - `dispatchAsyncRuntimeError` — uncaught error via setTimeout
73
+ - `dispatchFinallyError` — unhandled rejection from Promise.finally
74
+ - `dispatchForceRetryError` — ForceRetryError to exercise the force-retry path
75
+ - `dispatchUnhandledRejection` — generic unhandled promise rejection
76
+ - `dispatchRetryExhausted` — simulates retry-exhausted state and renders fallback UI
77
+ - `dispatchStaticAsset404` — appends a hashed `<script>` that 404s, exercising the Resource Timing API-based static asset detection path
64
78
 
65
79
  ## Related packages
66
80
 
package/dist/_internal.js CHANGED
@@ -3,13 +3,13 @@ import {
3
3
  isChunkError,
4
4
  listenInternal,
5
5
  serializeError
6
- } from "./chunk-5ANASVKX.js";
6
+ } from "./chunk-CBAJOLBG.js";
7
7
  import {
8
8
  SPINNER_ID,
9
9
  defaultSpinnerSvg,
10
10
  extractVersionFromHtml,
11
11
  sanitizeCssValue
12
- } from "./chunk-BA5VUNSU.js";
12
+ } from "./chunk-5546JVQV.js";
13
13
  import {
14
14
  dispatchAsyncRuntimeError,
15
15
  dispatchChunkLoadError,
@@ -18,13 +18,13 @@ import {
18
18
  dispatchNetworkTimeout,
19
19
  dispatchSyncRuntimeError,
20
20
  dispatchUnhandledRejection
21
- } from "./chunk-62AC5565.js";
21
+ } from "./chunk-3LAQJKWC.js";
22
22
  import {
23
23
  attemptReload,
24
24
  sendBeacon,
25
25
  shouldForceRetry,
26
26
  shouldIgnoreMessages
27
- } from "./chunk-4EXCHJBE.js";
27
+ } from "./chunk-2BUEJXDB.js";
28
28
  import {
29
29
  applyI18n,
30
30
  debugSyncErrorEventType,
@@ -39,7 +39,7 @@ import {
39
39
  isDefaultRetryEnabled,
40
40
  optionsWindowKey,
41
41
  subscribe
42
- } from "./chunk-PE5QPP5Y.js";
42
+ } from "./chunk-LBHLKYQS.js";
43
43
  import "./chunk-MLKGABMK.js";
44
44
 
45
45
  // src/common/handleErrorWithSpaGuard.ts
@@ -8,6 +8,7 @@ import {
8
8
  clearRetryAttemptFromUrl,
9
9
  clearRetryStateFromUrl,
10
10
  emitEvent,
11
+ fallbackModeKey,
11
12
  generateRetryId,
12
13
  getI18n,
13
14
  getLastReloadTime,
@@ -20,7 +21,27 @@ import {
20
21
  setLastReloadTime,
21
22
  setLastRetryResetInfo,
22
23
  shouldResetRetryCycle
23
- } from "./chunk-PE5QPP5Y.js";
24
+ } from "./chunk-LBHLKYQS.js";
25
+
26
+ // src/common/fallbackState.ts
27
+ var isInFallbackMode = () => {
28
+ if (globalThis.window === void 0) {
29
+ return false;
30
+ }
31
+ return globalThis.window[fallbackModeKey] === true;
32
+ };
33
+ var setFallbackMode = () => {
34
+ if (globalThis.window === void 0) {
35
+ return;
36
+ }
37
+ globalThis.window[fallbackModeKey] = true;
38
+ };
39
+ var resetFallbackMode = () => {
40
+ if (globalThis.window === void 0) {
41
+ return;
42
+ }
43
+ globalThis.window[fallbackModeKey] = false;
44
+ };
24
45
 
25
46
  // src/common/shouldIgnore.ts
26
47
  var shouldIgnoreMessages = (messages) => {
@@ -93,6 +114,10 @@ var getReloadState = () => {
93
114
  return globalThis.window[reloadScheduledKey] ?? (globalThis.window[reloadScheduledKey] = { scheduled: false });
94
115
  };
95
116
  var attemptReload = (error, opts) => {
117
+ if (isInFallbackMode()) {
118
+ getLogger()?.fallbackAlreadyShown(error);
119
+ return;
120
+ }
96
121
  const reloadState = getReloadState();
97
122
  if (reloadState.scheduled) {
98
123
  getLogger()?.reloadAlreadyScheduled(error);
@@ -253,6 +278,10 @@ var showLoadingUI = (attempt) => {
253
278
  }
254
279
  };
255
280
  var showFallbackUI = () => {
281
+ if (isInFallbackMode()) {
282
+ return;
283
+ }
284
+ setFallbackMode();
256
285
  const options = getOptions();
257
286
  const fallbackHtml = options.html?.fallback?.content;
258
287
  const selector = options.html?.fallback?.selector ?? "body";
@@ -300,6 +329,8 @@ var showFallbackUI = () => {
300
329
  };
301
330
 
302
331
  export {
332
+ isInFallbackMode,
333
+ resetFallbackMode,
303
334
  shouldIgnoreMessages,
304
335
  shouldForceRetry,
305
336
  sendBeacon,
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  showFallbackUI
3
- } from "./chunk-4EXCHJBE.js";
3
+ } from "./chunk-2BUEJXDB.js";
4
4
  import {
5
5
  ForceRetryError,
6
6
  debugSyncErrorEventType,
7
7
  emitEvent,
8
8
  getOptions
9
- } from "./chunk-PE5QPP5Y.js";
9
+ } from "./chunk-LBHLKYQS.js";
10
10
 
11
11
  // src/runtime/debug/errorDispatchers.ts
12
12
  function dispatchAsyncRuntimeError() {
@@ -36,7 +36,7 @@ function dispatchNetworkTimeout(delayMs = 3e3) {
36
36
  }
37
37
  function dispatchRetryExhausted() {
38
38
  const options = getOptions();
39
- const reloadDelays = options.reloadDelays ?? [1e3, 2e3, 5e3];
39
+ const reloadDelays = options.reloadDelays ?? [];
40
40
  emitEvent({
41
41
  finalAttempt: reloadDelays.length,
42
42
  name: "retry-exhausted",
@@ -45,9 +45,12 @@ function dispatchRetryExhausted() {
45
45
  showFallbackUI();
46
46
  }
47
47
  function dispatchStaticAsset404() {
48
- const hash = crypto.randomUUID().replaceAll("-", "").slice(0, 8);
48
+ const hash = crypto.randomUUID().slice(0, 8);
49
49
  const script = document.createElement("script");
50
50
  script.src = `/assets/index-${hash}.js`;
51
+ script.addEventListener("error", () => {
52
+ script.remove();
53
+ });
51
54
  document.head.append(script);
52
55
  }
53
56
  function dispatchSyncRuntimeError() {
@@ -2,7 +2,7 @@ import {
2
2
  defaultSpinnerHtml,
3
3
  getOptions,
4
4
  spinnerStateWindowKey
5
- } from "./chunk-PE5QPP5Y.js";
5
+ } from "./chunk-LBHLKYQS.js";
6
6
 
7
7
  // src/common/parseVersion.ts
8
8
  function extractVersionFromHtml(html) {
@@ -1,9 +1,10 @@
1
1
  import {
2
2
  attemptReload,
3
+ isInFallbackMode,
3
4
  sendBeacon,
4
5
  shouldForceRetry,
5
6
  shouldIgnoreMessages
6
- } from "./chunk-4EXCHJBE.js";
7
+ } from "./chunk-2BUEJXDB.js";
7
8
  import {
8
9
  clearRetryStateFromUrl,
9
10
  emitEvent,
@@ -16,7 +17,7 @@ import {
16
17
  setLogger,
17
18
  staticAssetRecoveryKey,
18
19
  updateRetryStateInUrl
19
- } from "./chunk-PE5QPP5Y.js";
20
+ } from "./chunk-LBHLKYQS.js";
20
21
 
21
22
  // src/common/isChunkError.ts
22
23
  var isChunkError = (error) => {
@@ -182,6 +183,9 @@ var isStaticAssetError = (event) => {
182
183
  return false;
183
184
  };
184
185
  var checkResourceStatus = (url) => {
186
+ if (typeof performance === "undefined" || typeof performance.getEntriesByName !== "function") {
187
+ return true;
188
+ }
185
189
  const entries = performance.getEntriesByName(url, "resource");
186
190
  if (entries.length === 0) {
187
191
  return true;
@@ -195,11 +199,12 @@ var checkResourceStatus = (url) => {
195
199
  }
196
200
  return false;
197
201
  };
198
- var isLikely404 = (url, timeSinceNavMs = typeof performance === "undefined" ? 0 : performance.now()) => {
202
+ var isLikely404 = (url, timeSinceNavMs) => {
199
203
  if (url !== void 0) {
200
204
  return checkResourceStatus(url);
201
205
  }
202
- return timeSinceNavMs > 3e4;
206
+ const elapsed = timeSinceNavMs ?? (typeof performance === "undefined" ? 0 : performance.now());
207
+ return elapsed > 3e4;
203
208
  };
204
209
  var getAssetUrl = (event) => {
205
210
  const target = event.target;
@@ -226,6 +231,12 @@ var getState = () => {
226
231
  return globalThis.window[staticAssetRecoveryKey];
227
232
  };
228
233
  var handleStaticAssetFailure = (url) => {
234
+ if (globalThis.window === void 0) {
235
+ return;
236
+ }
237
+ if (isInFallbackMode()) {
238
+ return;
239
+ }
229
240
  const state = getState();
230
241
  state.failedAssets.add(url);
231
242
  if (state.recoveryTimer !== null) {
@@ -235,9 +246,12 @@ var handleStaticAssetFailure = (url) => {
235
246
  const delay = options.staticAssets?.recoveryDelay ?? 500;
236
247
  state.recoveryTimer = setTimeout(() => {
237
248
  const s = getState();
249
+ s.recoveryTimer = null;
238
250
  const assets = [...s.failedAssets];
239
251
  s.failedAssets = /* @__PURE__ */ new Set();
240
- s.recoveryTimer = null;
252
+ if (isInFallbackMode()) {
253
+ return;
254
+ }
241
255
  const error = new Error(`Static asset load failed: ${assets.join(", ")}`);
242
256
  attemptReload(error, { cacheBust: true });
243
257
  }, delay);
@@ -260,16 +274,17 @@ var listenInternal = (serializeError2, logger) => {
260
274
  updateRetryStateInUrl(retryState.retryId, -1);
261
275
  }
262
276
  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
- );
277
+ const cleanupSentinel = () => {
278
+ const current = getRetryStateFromUrl();
279
+ if (current?.retryAttempt === -1) {
280
+ clearRetryStateFromUrl();
281
+ }
282
+ };
283
+ if (globalThis.window.document?.readyState === "complete") {
284
+ cleanupSentinel();
285
+ } else {
286
+ globalThis.window.addEventListener("load", cleanupSentinel, { once: true });
287
+ }
273
288
  }
274
289
  const wa = globalThis.window.addEventListener.bind(globalThis.window);
275
290
  wa(
@@ -20,6 +20,7 @@ var inMemoryLastReloadKey = /* @__PURE__ */ Symbol.for(`${name}:in-memory-last-r
20
20
  var staticAssetRecoveryKey = /* @__PURE__ */ Symbol.for(`${name}:static-asset-recovery`);
21
21
  var debugSyncErrorEventType = "spa-guard:debug-sync-error";
22
22
  var spinnerStateWindowKey = /* @__PURE__ */ Symbol.for(`${name}:spinner-state`);
23
+ var fallbackModeKey = /* @__PURE__ */ Symbol.for(`${name}:fallback-mode`);
23
24
 
24
25
  // src/common/events/internal.ts
25
26
  if (globalThis.window && !globalThis.window[eventSubscribersWindowKey]) {
@@ -459,6 +460,7 @@ export {
459
460
  staticAssetRecoveryKey,
460
461
  debugSyncErrorEventType,
461
462
  spinnerStateWindowKey,
463
+ fallbackModeKey,
462
464
  setLogger,
463
465
  getLogger,
464
466
  emitEvent,
@@ -3,7 +3,7 @@ import {
3
3
  getRetryAttemptFromUrl,
4
4
  getRetryStateFromUrl,
5
5
  subscribe
6
- } from "./chunk-PE5QPP5Y.js";
6
+ } from "./chunk-LBHLKYQS.js";
7
7
 
8
8
  // src/runtime/state.ts
9
9
  var getInitialStateFromUrl = () => {
@@ -12,3 +12,4 @@ export declare const inMemoryLastReloadKey: unique symbol;
12
12
  export declare const staticAssetRecoveryKey: unique symbol;
13
13
  export declare const debugSyncErrorEventType = "spa-guard:debug-sync-error";
14
14
  export declare const spinnerStateWindowKey: unique symbol;
15
+ export declare const fallbackModeKey: unique symbol;
@@ -0,0 +1,3 @@
1
+ export declare const isInFallbackMode: () => boolean;
2
+ export declare const setFallbackMode: () => void;
3
+ export declare const resetFallbackMode: () => void;
@@ -2,5 +2,6 @@ export { BeaconError } from "./errors/BeaconError";
2
2
  export { ForceRetryError } from "./errors/ForceRetryError";
3
3
  export * as events from "./events";
4
4
  export { disableDefaultRetry, enableDefaultRetry, isDefaultRetryEnabled } from "./events/internal";
5
+ export { isInFallbackMode, resetFallbackMode } from "./fallbackState";
5
6
  export { listen } from "./listen";
6
7
  export * as options from "./options";
@@ -2,8 +2,11 @@ import {
2
2
  createLogger,
3
3
  listenInternal,
4
4
  serializeError
5
- } from "../chunk-5ANASVKX.js";
6
- import "../chunk-4EXCHJBE.js";
5
+ } from "../chunk-CBAJOLBG.js";
6
+ import {
7
+ isInFallbackMode,
8
+ resetFallbackMode
9
+ } from "../chunk-2BUEJXDB.js";
7
10
  import {
8
11
  ForceRetryError,
9
12
  disableDefaultRetry,
@@ -12,7 +15,7 @@ import {
12
15
  isDefaultRetryEnabled,
13
16
  options_exports,
14
17
  subscribe
15
- } from "../chunk-PE5QPP5Y.js";
18
+ } from "../chunk-LBHLKYQS.js";
16
19
  import {
17
20
  __export
18
21
  } from "../chunk-MLKGABMK.js";
@@ -73,6 +76,8 @@ export {
73
76
  enableDefaultRetry,
74
77
  events_exports as events,
75
78
  isDefaultRetryEnabled,
79
+ isInFallbackMode,
76
80
  listen,
77
- options_exports as options
81
+ options_exports as options,
82
+ resetFallbackMode
78
83
  };
@@ -8,14 +8,14 @@ import {
8
8
  dispatchStaticAsset404,
9
9
  dispatchSyncRuntimeError,
10
10
  dispatchUnhandledRejection
11
- } from "../../chunk-62AC5565.js";
12
- import "../../chunk-4EXCHJBE.js";
11
+ } from "../../chunk-3LAQJKWC.js";
12
+ import "../../chunk-2BUEJXDB.js";
13
13
  import {
14
14
  subscribeToState
15
- } from "../../chunk-B7C3LV2W.js";
15
+ } from "../../chunk-W3TOHCEW.js";
16
16
  import {
17
17
  subscribe
18
- } from "../../chunk-PE5QPP5Y.js";
18
+ } from "../../chunk-LBHLKYQS.js";
19
19
  import "../../chunk-MLKGABMK.js";
20
20
 
21
21
  // src/runtime/debug/index.ts
@@ -3,18 +3,18 @@ import {
3
3
  extractVersionFromHtml,
4
4
  getSpinnerHtml,
5
5
  showSpinner
6
- } from "../chunk-BA5VUNSU.js";
6
+ } from "../chunk-5546JVQV.js";
7
7
  import {
8
8
  getState,
9
9
  subscribeToState
10
- } from "../chunk-B7C3LV2W.js";
10
+ } from "../chunk-W3TOHCEW.js";
11
11
  import {
12
12
  ForceRetryError,
13
13
  getLogger,
14
14
  getOptions,
15
15
  setTranslations,
16
16
  versionCheckStateWindowKey
17
- } from "../chunk-PE5QPP5Y.js";
17
+ } from "../chunk-LBHLKYQS.js";
18
18
  import "../chunk-MLKGABMK.js";
19
19
 
20
20
  // src/common/checkVersion.ts
@@ -12,6 +12,8 @@ var STRING_FIELDS = [
12
12
  "serialized",
13
13
  "url"
14
14
  ];
15
+ var MAX_STRING_FIELD_LENGTH = 500;
16
+ var MAX_SERIALIZED_LENGTH = 1e4;
15
17
  function parseBeacon(data) {
16
18
  if (!data || typeof data !== "object" || Array.isArray(data)) {
17
19
  throw new Error("Invalid beacon");
@@ -23,13 +25,17 @@ function parseBeacon(data) {
23
25
  if (typeof d[field] !== "string") {
24
26
  throw new TypeError(`Beacon validation failed: ${field} must be a string`);
25
27
  }
28
+ const maxLen = field === "serialized" ? MAX_SERIALIZED_LENGTH : MAX_STRING_FIELD_LENGTH;
29
+ if (d[field].length > maxLen) {
30
+ throw new TypeError(`Beacon validation failed: ${field} exceeds maximum length`);
31
+ }
26
32
  result[field] = d[field];
27
33
  }
28
34
  }
29
35
  for (const field of ["retryAttempt", "httpStatus"]) {
30
36
  if (field in d) {
31
- if (typeof d[field] !== "number") {
32
- throw new TypeError(`Beacon validation failed: ${field} must be a number`);
37
+ if (typeof d[field] !== "number" || !Number.isFinite(d[field])) {
38
+ throw new TypeError(`Beacon validation failed: ${field} must be a finite number`);
33
39
  }
34
40
  result[field] = d[field];
35
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ovineko/spa-guard",
3
- "version": "0.0.1-alpha-28",
3
+ "version": "0.0.1-alpha-29",
4
4
  "description": "Chunk load error handling for SPAs — core runtime, error handling, schema, i18n",
5
5
  "keywords": [
6
6
  "spa",