@ovineko/spa-guard 0.0.1-alpha-27 → 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
 
@@ -15,5 +15,5 @@ export { extractVersionFromHtml } from "./common/parseVersion";
15
15
  export { attemptReload } from "./common/reload";
16
16
  export { retryImport } from "./common/retryImport";
17
17
  export { serializeError } from "./common/serializeError";
18
- export { defaultSpinnerSvg, SPINNER_ID } from "./common/spinner";
18
+ export { defaultSpinnerSvg, sanitizeCssValue, SPINNER_ID } from "./common/spinner";
19
19
  export { dispatchAsyncRuntimeError, dispatchChunkLoadError, dispatchFinallyError, dispatchForceRetryError, dispatchNetworkTimeout, dispatchSyncRuntimeError, dispatchUnhandledRejection, } from "./runtime/debug/errorDispatchers";
package/dist/_internal.js CHANGED
@@ -3,12 +3,13 @@ import {
3
3
  isChunkError,
4
4
  listenInternal,
5
5
  serializeError
6
- } from "./chunk-FRCJOMRP.js";
6
+ } from "./chunk-CBAJOLBG.js";
7
7
  import {
8
8
  SPINNER_ID,
9
9
  defaultSpinnerSvg,
10
- extractVersionFromHtml
11
- } from "./chunk-7PDOUY6W.js";
10
+ extractVersionFromHtml,
11
+ sanitizeCssValue
12
+ } from "./chunk-5546JVQV.js";
12
13
  import {
13
14
  dispatchAsyncRuntimeError,
14
15
  dispatchChunkLoadError,
@@ -17,13 +18,13 @@ import {
17
18
  dispatchNetworkTimeout,
18
19
  dispatchSyncRuntimeError,
19
20
  dispatchUnhandledRejection
20
- } from "./chunk-7KNE2UWX.js";
21
+ } from "./chunk-3LAQJKWC.js";
21
22
  import {
22
23
  attemptReload,
23
24
  sendBeacon,
24
25
  shouldForceRetry,
25
26
  shouldIgnoreMessages
26
- } from "./chunk-KAH6PVTJ.js";
27
+ } from "./chunk-2BUEJXDB.js";
27
28
  import {
28
29
  applyI18n,
29
30
  debugSyncErrorEventType,
@@ -38,7 +39,7 @@ import {
38
39
  isDefaultRetryEnabled,
39
40
  optionsWindowKey,
40
41
  subscribe
41
- } from "./chunk-67WBMNUS.js";
42
+ } from "./chunk-LBHLKYQS.js";
42
43
  import "./chunk-MLKGABMK.js";
43
44
 
44
45
  // src/common/handleErrorWithSpaGuard.ts
@@ -96,7 +97,9 @@ var retryImport = async (importFn, delays, options) => {
96
97
  let lastError = new Error("Import failed after all retry attempts");
97
98
  const totalAttempts = delays.length + 1;
98
99
  const startTime = Date.now();
99
- emitEvent({ name: "lazy-retry-start", totalAttempts });
100
+ if (delays.length > 0) {
101
+ emitEvent({ name: "lazy-retry-start", totalAttempts });
102
+ }
100
103
  for (let attempt = 0; attempt < totalAttempts; attempt++) {
101
104
  if (signal?.aborted) {
102
105
  throw signal.reason ?? new DOMException("Aborted", "AbortError");
@@ -160,6 +163,7 @@ export {
160
163
  logMessage,
161
164
  optionsWindowKey,
162
165
  retryImport,
166
+ sanitizeCssValue,
163
167
  serializeError,
164
168
  subscribe
165
169
  };
@@ -1,4 +1,5 @@
1
1
  import {
2
+ CACHE_BUST_PARAM,
2
3
  FORCE_RETRY_MAGIC,
3
4
  RETRY_ATTEMPT_PARAM,
4
5
  RETRY_ID_PARAM,
@@ -7,6 +8,7 @@ import {
7
8
  clearRetryAttemptFromUrl,
8
9
  clearRetryStateFromUrl,
9
10
  emitEvent,
11
+ fallbackModeKey,
10
12
  generateRetryId,
11
13
  getI18n,
12
14
  getLastReloadTime,
@@ -19,7 +21,27 @@ import {
19
21
  setLastReloadTime,
20
22
  setLastRetryResetInfo,
21
23
  shouldResetRetryCycle
22
- } from "./chunk-67WBMNUS.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
+ };
23
45
 
24
46
  // src/common/shouldIgnore.ts
25
47
  var shouldIgnoreMessages = (messages) => {
@@ -92,6 +114,10 @@ var getReloadState = () => {
92
114
  return globalThis.window[reloadScheduledKey] ?? (globalThis.window[reloadScheduledKey] = { scheduled: false });
93
115
  };
94
116
  var attemptReload = (error, opts) => {
117
+ if (isInFallbackMode()) {
118
+ getLogger()?.fallbackAlreadyShown(error);
119
+ return;
120
+ }
95
121
  const reloadState = getReloadState();
96
122
  if (reloadState.scheduled) {
97
123
  getLogger()?.reloadAlreadyScheduled(error);
@@ -202,7 +228,7 @@ var attemptReload = (error, opts) => {
202
228
  reloadUrl = useRetryId ? buildReloadUrl(retryId, nextAttempt) : buildReloadUrlAttemptOnly(nextAttempt);
203
229
  if (opts?.cacheBust) {
204
230
  const url = new URL(reloadUrl);
205
- url.searchParams.set("spaGuardCacheBust", String(Date.now()));
231
+ url.searchParams.set(CACHE_BUST_PARAM, String(Date.now()));
206
232
  reloadUrl = url.toString();
207
233
  }
208
234
  globalThis.window.location.href = reloadUrl;
@@ -252,6 +278,10 @@ var showLoadingUI = (attempt) => {
252
278
  }
253
279
  };
254
280
  var showFallbackUI = () => {
281
+ if (isInFallbackMode()) {
282
+ return;
283
+ }
284
+ setFallbackMode();
255
285
  const options = getOptions();
256
286
  const fallbackHtml = options.html?.fallback?.content;
257
287
  const selector = options.html?.fallback?.selector ?? "body";
@@ -299,6 +329,8 @@ var showFallbackUI = () => {
299
329
  };
300
330
 
301
331
  export {
332
+ isInFallbackMode,
333
+ resetFallbackMode,
302
334
  shouldIgnoreMessages,
303
335
  shouldForceRetry,
304
336
  sendBeacon,
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  showFallbackUI
3
- } from "./chunk-KAH6PVTJ.js";
3
+ } from "./chunk-2BUEJXDB.js";
4
4
  import {
5
5
  ForceRetryError,
6
6
  debugSyncErrorEventType,
7
7
  emitEvent,
8
8
  getOptions
9
- } from "./chunk-67WBMNUS.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",
@@ -44,6 +44,15 @@ function dispatchRetryExhausted() {
44
44
  });
45
45
  showFallbackUI();
46
46
  }
47
+ function dispatchStaticAsset404() {
48
+ const hash = crypto.randomUUID().slice(0, 8);
49
+ const script = document.createElement("script");
50
+ script.src = `/assets/index-${hash}.js`;
51
+ script.addEventListener("error", () => {
52
+ script.remove();
53
+ });
54
+ document.head.append(script);
55
+ }
47
56
  function dispatchSyncRuntimeError() {
48
57
  const error = new Error("Simulated sync runtime error from spa-guard debug panel");
49
58
  globalThis.dispatchEvent(new CustomEvent(debugSyncErrorEventType, { detail: { error } }));
@@ -61,6 +70,7 @@ export {
61
70
  dispatchForceRetryError,
62
71
  dispatchNetworkTimeout,
63
72
  dispatchRetryExhausted,
73
+ dispatchStaticAsset404,
64
74
  dispatchSyncRuntimeError,
65
75
  dispatchUnhandledRejection
66
76
  };
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  defaultSpinnerHtml,
3
- getOptions
4
- } from "./chunk-67WBMNUS.js";
3
+ getOptions,
4
+ spinnerStateWindowKey
5
+ } from "./chunk-LBHLKYQS.js";
5
6
 
6
7
  // src/common/parseVersion.ts
7
8
  function extractVersionFromHtml(html) {
@@ -10,7 +11,12 @@ function extractVersionFromHtml(html) {
10
11
  if (versionMatch?.[1]) {
11
12
  return versionMatch[1];
12
13
  }
13
- const optionsMatch = collapsed.match(
14
+ const markerIdx = collapsed.indexOf("__SPA_GUARD_OPTIONS__");
15
+ if (markerIdx === -1) {
16
+ return null;
17
+ }
18
+ const segment = collapsed.slice(markerIdx, markerIdx + 1e3);
19
+ const optionsMatch = segment.match(
14
20
  /__SPA_GUARD_OPTIONS__\s*=\s*\{.*?"?version"?\s*:\s*"([^"]+)"/
15
21
  );
16
22
  return optionsMatch?.[1] ?? null;
@@ -19,7 +25,12 @@ function extractVersionFromHtml(html) {
19
25
  // src/common/spinner.ts
20
26
  var SPINNER_ID = "__spa-guard-spinner";
21
27
  var defaultSpinnerSvg = defaultSpinnerHtml;
22
- var savedOverflow = null;
28
+ var getState = () => {
29
+ if (!globalThis.window[spinnerStateWindowKey]) {
30
+ globalThis.window[spinnerStateWindowKey] = { savedOverflow: null };
31
+ }
32
+ return globalThis.window[spinnerStateWindowKey];
33
+ };
23
34
  function dismissSpinner() {
24
35
  if (typeof document === "undefined") {
25
36
  return;
@@ -28,20 +39,22 @@ function dismissSpinner() {
28
39
  if (el) {
29
40
  el.remove();
30
41
  }
31
- if (savedOverflow !== null) {
32
- document.body.style.overflow = savedOverflow;
33
- savedOverflow = null;
42
+ const state = getState();
43
+ if (state.savedOverflow !== null) {
44
+ document.body.style.overflow = state.savedOverflow;
45
+ state.savedOverflow = null;
34
46
  } else if (el) {
35
47
  document.body.style.overflow = "";
36
48
  }
37
49
  }
50
+ var sanitizeCssValue = (value) => value.replaceAll(/["'<>\\{};\n]/g, "");
38
51
  function getSpinnerHtml(backgroundOverride) {
39
52
  const opts = getOptions();
40
53
  if (opts.html?.spinner?.disabled) {
41
54
  return "";
42
55
  }
43
56
  const spinnerContent = opts.html?.spinner?.content ?? defaultSpinnerSvg;
44
- const bg = backgroundOverride ?? opts.html?.spinner?.background ?? "#fff";
57
+ const bg = sanitizeCssValue(backgroundOverride ?? opts.html?.spinner?.background ?? "#fff");
45
58
  return `<div id="${SPINNER_ID}" style="position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:var(--spa-guard-spinner-bg,${bg})">${spinnerContent}</div>`;
46
59
  }
47
60
  function showSpinner(options) {
@@ -67,7 +80,7 @@ function showSpinner(options) {
67
80
  wrapper.innerHTML = html;
68
81
  const overlay = wrapper.firstElementChild;
69
82
  if (!existing) {
70
- savedOverflow = document.body.style.overflow;
83
+ getState().savedOverflow = document.body.style.overflow;
71
84
  }
72
85
  document.body.style.overflow = "hidden";
73
86
  document.body.append(overlay);
@@ -79,6 +92,7 @@ export {
79
92
  SPINNER_ID,
80
93
  defaultSpinnerSvg,
81
94
  dismissSpinner,
95
+ sanitizeCssValue,
82
96
  getSpinnerHtml,
83
97
  showSpinner
84
98
  };
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  attemptReload,
3
+ isInFallbackMode,
3
4
  sendBeacon,
4
5
  shouldForceRetry,
5
6
  shouldIgnoreMessages
6
- } from "./chunk-KAH6PVTJ.js";
7
+ } from "./chunk-2BUEJXDB.js";
7
8
  import {
9
+ clearRetryStateFromUrl,
8
10
  emitEvent,
9
11
  getLogger,
10
12
  getOptions,
@@ -13,8 +15,9 @@ import {
13
15
  isInitialized,
14
16
  markInitialized,
15
17
  setLogger,
18
+ staticAssetRecoveryKey,
16
19
  updateRetryStateInUrl
17
- } from "./chunk-67WBMNUS.js";
20
+ } from "./chunk-LBHLKYQS.js";
18
21
 
19
22
  // src/common/isChunkError.ts
20
23
  var isChunkError = (error) => {
@@ -179,8 +182,29 @@ var isStaticAssetError = (event) => {
179
182
  }
180
183
  return false;
181
184
  };
182
- var isLikely404 = (timeSinceNavMs = typeof performance === "undefined" ? 0 : performance.now()) => {
183
- return timeSinceNavMs > 3e4;
185
+ var checkResourceStatus = (url) => {
186
+ if (typeof performance === "undefined" || typeof performance.getEntriesByName !== "function") {
187
+ return true;
188
+ }
189
+ const entries = performance.getEntriesByName(url, "resource");
190
+ if (entries.length === 0) {
191
+ return true;
192
+ }
193
+ const entry = entries.at(-1);
194
+ if (entry.responseStatus >= 400) {
195
+ return true;
196
+ }
197
+ if (entry.transferSize === 0 && entry.decodedBodySize === 0) {
198
+ return true;
199
+ }
200
+ return false;
201
+ };
202
+ var isLikely404 = (url, timeSinceNavMs) => {
203
+ if (url !== void 0) {
204
+ return checkResourceStatus(url);
205
+ }
206
+ const elapsed = timeSinceNavMs ?? (typeof performance === "undefined" ? 0 : performance.now());
207
+ return elapsed > 3e4;
184
208
  };
185
209
  var getAssetUrl = (event) => {
186
210
  const target = event.target;
@@ -194,19 +218,40 @@ var getAssetUrl = (event) => {
194
218
  };
195
219
 
196
220
  // src/common/staticAssetRecovery.ts
197
- var recoveryTimer = null;
198
- var failedAssets = /* @__PURE__ */ new Set();
221
+ var getState = () => {
222
+ if (globalThis.window === void 0) {
223
+ return { failedAssets: /* @__PURE__ */ new Set(), recoveryTimer: null };
224
+ }
225
+ if (!globalThis.window[staticAssetRecoveryKey]) {
226
+ globalThis.window[staticAssetRecoveryKey] = {
227
+ failedAssets: /* @__PURE__ */ new Set(),
228
+ recoveryTimer: null
229
+ };
230
+ }
231
+ return globalThis.window[staticAssetRecoveryKey];
232
+ };
199
233
  var handleStaticAssetFailure = (url) => {
200
- failedAssets.add(url);
201
- if (recoveryTimer !== null) {
234
+ if (globalThis.window === void 0) {
235
+ return;
236
+ }
237
+ if (isInFallbackMode()) {
238
+ return;
239
+ }
240
+ const state = getState();
241
+ state.failedAssets.add(url);
242
+ if (state.recoveryTimer !== null) {
202
243
  return;
203
244
  }
204
245
  const options = getOptions();
205
246
  const delay = options.staticAssets?.recoveryDelay ?? 500;
206
- recoveryTimer = setTimeout(() => {
207
- const assets = [...failedAssets];
208
- failedAssets = /* @__PURE__ */ new Set();
209
- recoveryTimer = null;
247
+ state.recoveryTimer = setTimeout(() => {
248
+ const s = getState();
249
+ s.recoveryTimer = null;
250
+ const assets = [...s.failedAssets];
251
+ s.failedAssets = /* @__PURE__ */ new Set();
252
+ if (isInFallbackMode()) {
253
+ return;
254
+ }
210
255
  const error = new Error(`Static asset load failed: ${assets.join(", ")}`);
211
256
  attemptReload(error, { cacheBust: true });
212
257
  }, delay);
@@ -214,12 +259,12 @@ var handleStaticAssetFailure = (url) => {
214
259
 
215
260
  // src/common/listen/internal.ts
216
261
  var listenInternal = (serializeError2, logger) => {
217
- if (logger) {
218
- setLogger(logger);
219
- }
220
262
  if (isInitialized()) {
221
263
  return;
222
264
  }
265
+ if (logger) {
266
+ setLogger(logger);
267
+ }
223
268
  markInitialized();
224
269
  const options = getOptions();
225
270
  const reloadDelays = options.reloadDelays ?? [];
@@ -228,10 +273,35 @@ var listenInternal = (serializeError2, logger) => {
228
273
  getLogger()?.retryLimitExceeded(retryState.retryAttempt, reloadDelays.length);
229
274
  updateRetryStateInUrl(retryState.retryId, -1);
230
275
  }
276
+ if (retryState && (retryState.retryAttempt >= reloadDelays.length || retryState.retryAttempt === -1)) {
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
+ }
288
+ }
231
289
  const wa = globalThis.window.addEventListener.bind(globalThis.window);
232
290
  wa(
233
291
  "error",
234
292
  (event) => {
293
+ const assetUrl = getAssetUrl(event);
294
+ if (isStaticAssetError(event) && isLikely404(assetUrl)) {
295
+ if (shouldIgnoreMessages([assetUrl, event.message])) {
296
+ return;
297
+ }
298
+ event.preventDefault();
299
+ emitEvent({ name: "static-asset-load-failed", url: assetUrl });
300
+ if (options.staticAssets?.autoRecover !== false) {
301
+ handleStaticAssetFailure(assetUrl);
302
+ }
303
+ return;
304
+ }
235
305
  if (shouldIgnoreMessages([event.message])) {
236
306
  return;
237
307
  }
@@ -246,15 +316,6 @@ var listenInternal = (serializeError2, logger) => {
246
316
  attemptReload(event.error ?? event);
247
317
  return;
248
318
  }
249
- if (isStaticAssetError(event) && isLikely404()) {
250
- const assetUrl = getAssetUrl(event);
251
- event.preventDefault();
252
- emitEvent({ name: "static-asset-load-failed", url: assetUrl });
253
- if (options.staticAssets?.autoRecover !== false) {
254
- handleStaticAssetFailure(assetUrl);
255
- }
256
- return;
257
- }
258
319
  const serialized = serializeError2(event);
259
320
  sendBeacon({
260
321
  errorMessage: event.message,
@@ -13,10 +13,14 @@ var initializedKey = /* @__PURE__ */ Symbol.for(`${name}:initialized`);
13
13
  var loggerWindowKey = /* @__PURE__ */ Symbol.for(`${name}:logger`);
14
14
  var RETRY_ID_PARAM = "spaGuardRetryId";
15
15
  var RETRY_ATTEMPT_PARAM = "spaGuardRetryAttempt";
16
+ var CACHE_BUST_PARAM = "spaGuardCacheBust";
16
17
  var versionCheckStateWindowKey = /* @__PURE__ */ Symbol.for(`${name}:version-check-state`);
17
18
  var reloadScheduledKey = /* @__PURE__ */ Symbol.for(`${name}:reload-scheduled`);
18
19
  var inMemoryLastReloadKey = /* @__PURE__ */ Symbol.for(`${name}:in-memory-last-reload`);
20
+ var staticAssetRecoveryKey = /* @__PURE__ */ Symbol.for(`${name}:static-asset-recovery`);
19
21
  var debugSyncErrorEventType = "spa-guard:debug-sync-error";
22
+ var spinnerStateWindowKey = /* @__PURE__ */ Symbol.for(`${name}:spinner-state`);
23
+ var fallbackModeKey = /* @__PURE__ */ Symbol.for(`${name}:fallback-mode`);
20
24
 
21
25
  // src/common/events/internal.ts
22
26
  if (globalThis.window && !globalThis.window[eventSubscribersWindowKey]) {
@@ -368,7 +372,7 @@ var getRetryStateFromUrl = () => {
368
372
  const retryAttempt = params.get(RETRY_ATTEMPT_PARAM);
369
373
  if (retryId && retryAttempt) {
370
374
  const parsed = parseInt(retryAttempt, 10);
371
- if (Number.isNaN(parsed)) {
375
+ if (Number.isNaN(parsed) || parsed < -1) {
372
376
  return null;
373
377
  }
374
378
  return {
@@ -386,6 +390,7 @@ var clearRetryStateFromUrl = () => {
386
390
  const url = new URL(globalThis.window.location.href);
387
391
  url.searchParams.delete(RETRY_ID_PARAM);
388
392
  url.searchParams.delete(RETRY_ATTEMPT_PARAM);
393
+ url.searchParams.delete(CACHE_BUST_PARAM);
389
394
  globalThis.window.history.replaceState(null, "", url.toString());
390
395
  } catch {
391
396
  }
@@ -416,7 +421,7 @@ var getRetryAttemptFromUrl = () => {
416
421
  const retryAttempt = params.get(RETRY_ATTEMPT_PARAM);
417
422
  if (retryAttempt) {
418
423
  const parsed = parseInt(retryAttempt, 10);
419
- if (Number.isNaN(parsed)) {
424
+ if (Number.isNaN(parsed) || parsed < -1) {
420
425
  return null;
421
426
  }
422
427
  return parsed;
@@ -449,9 +454,13 @@ export {
449
454
  optionsWindowKey,
450
455
  RETRY_ID_PARAM,
451
456
  RETRY_ATTEMPT_PARAM,
457
+ CACHE_BUST_PARAM,
452
458
  versionCheckStateWindowKey,
453
459
  reloadScheduledKey,
460
+ staticAssetRecoveryKey,
454
461
  debugSyncErrorEventType,
462
+ spinnerStateWindowKey,
463
+ fallbackModeKey,
455
464
  setLogger,
456
465
  getLogger,
457
466
  emitEvent,
@@ -3,7 +3,7 @@ import {
3
3
  getRetryAttemptFromUrl,
4
4
  getRetryStateFromUrl,
5
5
  subscribe
6
- } from "./chunk-67WBMNUS.js";
6
+ } from "./chunk-LBHLKYQS.js";
7
7
 
8
8
  // src/runtime/state.ts
9
9
  var getInitialStateFromUrl = () => {
@@ -5,7 +5,11 @@ export declare const initializedKey: unique symbol;
5
5
  export declare const loggerWindowKey: unique symbol;
6
6
  export declare const RETRY_ID_PARAM = "spaGuardRetryId";
7
7
  export declare const RETRY_ATTEMPT_PARAM = "spaGuardRetryAttempt";
8
+ export declare const CACHE_BUST_PARAM = "spaGuardCacheBust";
8
9
  export declare const versionCheckStateWindowKey: unique symbol;
9
10
  export declare const reloadScheduledKey: unique symbol;
10
11
  export declare const inMemoryLastReloadKey: unique symbol;
12
+ export declare const staticAssetRecoveryKey: unique symbol;
11
13
  export declare const debugSyncErrorEventType = "spa-guard:debug-sync-error";
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-FRCJOMRP.js";
6
- import "../chunk-KAH6PVTJ.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-67WBMNUS.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
  };
@@ -1,3 +1,3 @@
1
1
  export declare const isStaticAssetError: (event: Event) => boolean;
2
- export declare const isLikely404: (timeSinceNavMs?: number) => boolean;
2
+ export declare const isLikely404: (url?: string, timeSinceNavMs?: number) => boolean;
3
3
  export declare const getAssetUrl: (event: Event) => string;
@@ -1,6 +1,7 @@
1
1
  export declare const SPINNER_ID = "__spa-guard-spinner";
2
2
  export declare const defaultSpinnerSvg = "<svg width=\"40\" height=\"40\" viewBox=\"0 0 40 40\" style=\"animation:spa-guard-spin .8s linear infinite\"><circle cx=\"20\" cy=\"20\" r=\"16\" fill=\"none\" stroke=\"#e8e8e8\" stroke-width=\"3\"/><circle cx=\"20\" cy=\"20\" r=\"16\" fill=\"none\" stroke=\"#666\" stroke-width=\"3\" stroke-dasharray=\"80\" stroke-dashoffset=\"60\" stroke-linecap=\"round\"/></svg><style>@keyframes spa-guard-spin{to{transform:rotate(360deg)}}</style>";
3
3
  export declare function dismissSpinner(): void;
4
+ export declare const sanitizeCssValue: (value: string) => string;
4
5
  export declare function getSpinnerHtml(backgroundOverride?: string): string;
5
6
  export declare function showSpinner(options?: {
6
7
  background?: string;
@@ -41,6 +41,13 @@ export declare function dispatchNetworkTimeout(delayMs?: number): void;
41
41
  * the fallback UI into the DOM.
42
42
  */
43
43
  export declare function dispatchRetryExhausted(): void;
44
+ /**
45
+ * Simulates a static asset 404 by appending a <script> element with a
46
+ * nonexistent hashed URL to document.head. The browser fires an "error"
47
+ * event on the element, which spa-guard's listenInternal() detects via
48
+ * the Resource Timing API-based isLikely404 check.
49
+ */
50
+ export declare function dispatchStaticAsset404(): void;
44
51
  /**
45
52
  * Dispatches a sync runtime error via CustomEvent.
46
53
  * DebugSyncErrorTrigger (a React component) listens for this event,
@@ -5,16 +5,17 @@ import {
5
5
  dispatchForceRetryError,
6
6
  dispatchNetworkTimeout,
7
7
  dispatchRetryExhausted,
8
+ dispatchStaticAsset404,
8
9
  dispatchSyncRuntimeError,
9
10
  dispatchUnhandledRejection
10
- } from "../../chunk-7KNE2UWX.js";
11
- import "../../chunk-KAH6PVTJ.js";
11
+ } from "../../chunk-3LAQJKWC.js";
12
+ import "../../chunk-2BUEJXDB.js";
12
13
  import {
13
14
  subscribeToState
14
- } from "../../chunk-V2OOMVZK.js";
15
+ } from "../../chunk-W3TOHCEW.js";
15
16
  import {
16
17
  subscribe
17
- } from "../../chunk-67WBMNUS.js";
18
+ } from "../../chunk-LBHLKYQS.js";
18
19
  import "../../chunk-MLKGABMK.js";
19
20
 
20
21
  // src/runtime/debug/index.ts
@@ -30,7 +31,8 @@ var SCENARIOS = [
30
31
  key: "unhandled-rejection",
31
32
  label: "Unhandled Rejection"
32
33
  },
33
- { dispatch: dispatchRetryExhausted, key: "exhaust-retries", label: "Exhaust Retries" }
34
+ { dispatch: dispatchRetryExhausted, key: "exhaust-retries", label: "Exhaust Retries" },
35
+ { dispatch: dispatchStaticAsset404, key: "static-asset-404", label: "Static Asset 404" }
34
36
  ];
35
37
  var POSITION_MAP = {
36
38
  "bottom-left": "bottom:16px;left:16px;",
@@ -3,18 +3,18 @@ import {
3
3
  extractVersionFromHtml,
4
4
  getSpinnerHtml,
5
5
  showSpinner
6
- } from "../chunk-7PDOUY6W.js";
6
+ } from "../chunk-5546JVQV.js";
7
7
  import {
8
8
  getState,
9
9
  subscribeToState
10
- } from "../chunk-V2OOMVZK.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-67WBMNUS.js";
17
+ } from "../chunk-LBHLKYQS.js";
18
18
  import "../chunk-MLKGABMK.js";
19
19
 
20
20
  // src/common/checkVersion.ts
@@ -38,45 +38,60 @@ var getState2 = () => {
38
38
  }
39
39
  return globalThis.window[versionCheckStateWindowKey] ?? (globalThis.window[versionCheckStateWindowKey] = createInitialState());
40
40
  };
41
+ var FETCH_TIMEOUT_MS = 3e4;
41
42
  var fetchJsonVersion = async () => {
42
43
  const endpoint = getOptions().checkVersion?.endpoint;
43
44
  if (!endpoint) {
44
45
  getLogger()?.versionCheckRequiresEndpoint();
45
46
  return null;
46
47
  }
47
- const response = await fetch(endpoint, {
48
- cache: getOptions().checkVersion?.cache ?? "no-store",
49
- headers: { Accept: "application/json" }
50
- });
51
- if (!response.ok) {
52
- getLogger()?.versionCheckHttpError(response.status);
53
- return null;
54
- }
55
- const data = await response.json();
56
- if (typeof data !== "object" || data === null) {
57
- return null;
48
+ const controller = new AbortController();
49
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
50
+ try {
51
+ const response = await fetch(endpoint, {
52
+ cache: getOptions().checkVersion?.cache ?? "no-store",
53
+ headers: { Accept: "application/json" },
54
+ signal: controller.signal
55
+ });
56
+ if (!response.ok) {
57
+ getLogger()?.versionCheckHttpError(response.status);
58
+ return null;
59
+ }
60
+ const data = await response.json();
61
+ if (typeof data !== "object" || data === null) {
62
+ return null;
63
+ }
64
+ return "version" in data && typeof data.version === "string" ? data.version : null;
65
+ } finally {
66
+ clearTimeout(timeoutId);
58
67
  }
59
- return "version" in data && typeof data.version === "string" ? data.version : null;
60
68
  };
61
69
  var fetchHtmlVersion = async () => {
62
70
  const url = new URL(globalThis.location.href);
63
71
  url.search = "";
64
72
  url.hash = "";
65
- const response = await fetch(url.toString(), {
66
- cache: getOptions().checkVersion?.cache ?? "no-store",
67
- headers: { Accept: "text/html" }
68
- });
69
- if (!response.ok) {
70
- getLogger()?.versionCheckHttpError(response.status);
71
- return null;
72
- }
73
- const html = await response.text();
74
- const version = extractVersionFromHtml(html);
75
- if (!version) {
76
- getLogger()?.versionCheckParseError();
77
- return null;
73
+ const controller = new AbortController();
74
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
75
+ try {
76
+ const response = await fetch(url.toString(), {
77
+ cache: getOptions().checkVersion?.cache ?? "no-store",
78
+ headers: { Accept: "text/html" },
79
+ signal: controller.signal
80
+ });
81
+ if (!response.ok) {
82
+ getLogger()?.versionCheckHttpError(response.status);
83
+ return null;
84
+ }
85
+ const html = await response.text();
86
+ const version = extractVersionFromHtml(html);
87
+ if (!version) {
88
+ getLogger()?.versionCheckParseError();
89
+ return null;
90
+ }
91
+ return version;
92
+ } finally {
93
+ clearTimeout(timeoutId);
78
94
  }
79
- return version;
80
95
  };
81
96
  var fetchRemoteVersion = async (mode) => {
82
97
  return mode === "json" ? fetchJsonVersion() : fetchHtmlVersion();
@@ -143,6 +158,9 @@ var handleVisibilityHidden = () => {
143
158
  getLogger()?.versionCheckPaused();
144
159
  };
145
160
  var handleResume = (mode, interval) => {
161
+ if (document.visibilityState !== "visible" || !document.hasFocus()) {
162
+ return;
163
+ }
146
164
  const s = getState2();
147
165
  if (s.versionCheckInterval !== null || s.versionCheckTimeout !== null) {
148
166
  return;
@@ -243,7 +261,9 @@ var recommendedSetup = (overrides) => {
243
261
  startVersionCheck();
244
262
  }
245
263
  return () => {
246
- stopVersionCheck();
264
+ if (options.versionCheck) {
265
+ stopVersionCheck();
266
+ }
247
267
  };
248
268
  };
249
269
  export {
@@ -3,12 +3,17 @@ import "../chunk-MLKGABMK.js";
3
3
  // src/schema/parse.ts
4
4
  var STRING_FIELDS = [
5
5
  "appName",
6
+ "errorContext",
6
7
  "errorMessage",
8
+ "errorType",
7
9
  "eventMessage",
8
10
  "eventName",
9
11
  "retryId",
10
- "serialized"
12
+ "serialized",
13
+ "url"
11
14
  ];
15
+ var MAX_STRING_FIELD_LENGTH = 500;
16
+ var MAX_SERIALIZED_LENGTH = 1e4;
12
17
  function parseBeacon(data) {
13
18
  if (!data || typeof data !== "object" || Array.isArray(data)) {
14
19
  throw new Error("Invalid beacon");
@@ -20,14 +25,20 @@ function parseBeacon(data) {
20
25
  if (typeof d[field] !== "string") {
21
26
  throw new TypeError(`Beacon validation failed: ${field} must be a string`);
22
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
+ }
23
32
  result[field] = d[field];
24
33
  }
25
34
  }
26
- if ("retryAttempt" in d) {
27
- if (typeof d.retryAttempt !== "number") {
28
- throw new TypeError("Beacon validation failed: retryAttempt must be a number");
35
+ for (const field of ["retryAttempt", "httpStatus"]) {
36
+ if (field in d) {
37
+ if (typeof d[field] !== "number" || !Number.isFinite(d[field])) {
38
+ throw new TypeError(`Beacon validation failed: ${field} must be a finite number`);
39
+ }
40
+ result[field] = d[field];
29
41
  }
30
- result.retryAttempt = d.retryAttempt;
31
42
  }
32
43
  return result;
33
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ovineko/spa-guard",
3
- "version": "0.0.1-alpha-27",
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",