@ovineko/spa-guard 0.0.1-alpha-25 → 0.0.1-alpha-27

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 ADDED
@@ -0,0 +1,76 @@
1
+ # @ovineko/spa-guard
2
+
3
+ [![npm](https://img.shields.io/npm/v/@ovineko/spa-guard)](https://www.npmjs.com/package/@ovineko/spa-guard)
4
+ [![license](https://img.shields.io/npm/l/@ovineko/spa-guard)](./LICENSE)
5
+
6
+ Core runtime for spa-guard — chunk load error handling, version checking, spinner, i18n, and event schema for SPAs.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ npm install @ovineko/spa-guard
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ Call `recommendedSetup` once when your app boots. It dismisses the loading spinner, starts version checking, and returns a cleanup function.
17
+
18
+ ```ts
19
+ import { recommendedSetup } from "@ovineko/spa-guard/runtime";
20
+
21
+ const cleanup = recommendedSetup();
22
+ // optionally call cleanup() to stop background checks
23
+ ```
24
+
25
+ Disable version checking:
26
+
27
+ ```ts
28
+ const cleanup = recommendedSetup({ versionCheck: false });
29
+ ```
30
+
31
+ ## API
32
+
33
+ ### `@ovineko/spa-guard` (common)
34
+
35
+ - `listen` — subscribe to spa-guard events
36
+ - `events` — event type constants
37
+ - `options` — runtime options helpers
38
+ - `disableDefaultRetry` / `enableDefaultRetry` / `isDefaultRetryEnabled` — control default retry behaviour
39
+ - `BeaconError` — error class for beacon failures
40
+ - `ForceRetryError` — error class to force a retry
41
+
42
+ ### `@ovineko/spa-guard/runtime`
43
+
44
+ - `recommendedSetup(options?)` — enable recommended runtime features; returns cleanup function
45
+ - `RecommendedSetupOptions` — `{ versionCheck?: boolean }`
46
+ - `startVersionCheck` / `stopVersionCheck` — manual version check control
47
+ - `getState` / `subscribeToState` — runtime state access
48
+ - `SpaGuardState` — state type
49
+ - `showSpinner` / `dismissSpinner` / `getSpinnerHtml` — spinner helpers
50
+ - `setTranslations` — override i18n strings
51
+ - `ForceRetryError`
52
+
53
+ ### `@ovineko/spa-guard/schema`
54
+
55
+ Schemas for spa-guard configuration.
56
+
57
+ ### `@ovineko/spa-guard/i18n`
58
+
59
+ Built-in translation strings.
60
+
61
+ ### `@ovineko/spa-guard/runtime/debug`
62
+
63
+ Debug helpers for testing error scenarios.
64
+
65
+ ## Related packages
66
+
67
+ - [@ovineko/spa-guard-react](../react/README.md) — `lazyWithRetry` and React error boundary
68
+ - [@ovineko/spa-guard-react-router](../react-router/README.md) — React Router error boundary
69
+ - [@ovineko/spa-guard-vite](../vite/README.md) — Vite plugin (injects runtime script)
70
+ - [@ovineko/spa-guard-node](../node/README.md) — Node.js cache and builder API
71
+ - [@ovineko/spa-guard-fastify](../fastify/README.md) — Fastify plugin
72
+ - [@ovineko/spa-guard-eslint](../eslint/README.md) — ESLint rules
73
+
74
+ ## License
75
+
76
+ MIT
package/dist/_internal.js CHANGED
@@ -3,12 +3,12 @@ import {
3
3
  isChunkError,
4
4
  listenInternal,
5
5
  serializeError
6
- } from "./chunk-ZVYB2746.js";
6
+ } from "./chunk-FRCJOMRP.js";
7
7
  import {
8
8
  SPINNER_ID,
9
9
  defaultSpinnerSvg,
10
10
  extractVersionFromHtml
11
- } from "./chunk-Z75UPJWV.js";
11
+ } from "./chunk-7PDOUY6W.js";
12
12
  import {
13
13
  dispatchAsyncRuntimeError,
14
14
  dispatchChunkLoadError,
@@ -17,13 +17,13 @@ import {
17
17
  dispatchNetworkTimeout,
18
18
  dispatchSyncRuntimeError,
19
19
  dispatchUnhandledRejection
20
- } from "./chunk-XFILAQ7P.js";
20
+ } from "./chunk-7KNE2UWX.js";
21
21
  import {
22
22
  attemptReload,
23
23
  sendBeacon,
24
24
  shouldForceRetry,
25
25
  shouldIgnoreMessages
26
- } from "./chunk-CSN7MQGX.js";
26
+ } from "./chunk-KAH6PVTJ.js";
27
27
  import {
28
28
  applyI18n,
29
29
  debugSyncErrorEventType,
@@ -38,7 +38,7 @@ import {
38
38
  isDefaultRetryEnabled,
39
39
  optionsWindowKey,
40
40
  subscribe
41
- } from "./chunk-X6E7KUDK.js";
41
+ } from "./chunk-67WBMNUS.js";
42
42
  import "./chunk-MLKGABMK.js";
43
43
 
44
44
  // src/common/handleErrorWithSpaGuard.ts
@@ -95,6 +95,8 @@ var retryImport = async (importFn, delays, options) => {
95
95
  const { callReloadOnFailure, onRetry, signal } = options ?? {};
96
96
  let lastError = new Error("Import failed after all retry attempts");
97
97
  const totalAttempts = delays.length + 1;
98
+ const startTime = Date.now();
99
+ emitEvent({ name: "lazy-retry-start", totalAttempts });
98
100
  for (let attempt = 0; attempt < totalAttempts; attempt++) {
99
101
  if (signal?.aborted) {
100
102
  throw signal.reason ?? new DOMException("Aborted", "AbortError");
@@ -102,7 +104,7 @@ var retryImport = async (importFn, delays, options) => {
102
104
  try {
103
105
  const result = await importFn();
104
106
  if (attempt > 0) {
105
- emitEvent({ attempt, name: "lazy-retry-success" });
107
+ emitEvent({ attempt, name: "lazy-retry-success", totalTime: Date.now() - startTime });
106
108
  }
107
109
  return result;
108
110
  } catch (error) {
@@ -115,6 +117,7 @@ var retryImport = async (importFn, delays, options) => {
115
117
  emitEvent({
116
118
  attempt: attempt + 1,
117
119
  delay: currentDelay,
120
+ error: lastError,
118
121
  name: "lazy-retry-attempt",
119
122
  totalAttempts
120
123
  });
@@ -122,7 +125,7 @@ var retryImport = async (importFn, delays, options) => {
122
125
  }
123
126
  }
124
127
  const willReload = callReloadOnFailure === true && isChunkError(lastError) && isDefaultRetryEnabled();
125
- emitEvent({ name: "lazy-retry-exhausted", totalAttempts, willReload });
128
+ emitEvent({ error: lastError, name: "lazy-retry-exhausted", totalAttempts, willReload });
126
129
  if (willReload) {
127
130
  attemptReload(lastError);
128
131
  }
@@ -14,6 +14,8 @@ var loggerWindowKey = /* @__PURE__ */ Symbol.for(`${name}:logger`);
14
14
  var RETRY_ID_PARAM = "spaGuardRetryId";
15
15
  var RETRY_ATTEMPT_PARAM = "spaGuardRetryAttempt";
16
16
  var versionCheckStateWindowKey = /* @__PURE__ */ Symbol.for(`${name}:version-check-state`);
17
+ var reloadScheduledKey = /* @__PURE__ */ Symbol.for(`${name}:reload-scheduled`);
18
+ var inMemoryLastReloadKey = /* @__PURE__ */ Symbol.for(`${name}:in-memory-last-reload`);
17
19
  var debugSyncErrorEventType = "spa-guard:debug-sync-error";
18
20
 
19
21
  // src/common/events/internal.ts
@@ -163,6 +165,10 @@ var defaultOptions = {
163
165
  },
164
166
  loading: {
165
167
  content: defaultLoadingFallbackHtml
168
+ },
169
+ spinner: {
170
+ background: "#fff",
171
+ disabled: false
166
172
  }
167
173
  },
168
174
  lazyRetry: {
@@ -171,9 +177,9 @@ var defaultOptions = {
171
177
  },
172
178
  minTimeBetweenResets: 5e3,
173
179
  reloadDelays: [1e3, 2e3, 5e3],
174
- spinner: {
175
- background: "#fff",
176
- disabled: false
180
+ staticAssets: {
181
+ autoRecover: true,
182
+ recoveryDelay: 500
177
183
  },
178
184
  useRetryId: true
179
185
  };
@@ -202,6 +208,10 @@ var getOptions = () => {
202
208
  loading: {
203
209
  ...defaultOptions.html?.loading,
204
210
  ...windowOptions?.html?.loading
211
+ },
212
+ spinner: {
213
+ ...defaultOptions.html?.spinner,
214
+ ...windowOptions?.html?.spinner
205
215
  }
206
216
  },
207
217
  lazyRetry: {
@@ -212,9 +222,9 @@ var getOptions = () => {
212
222
  ...defaultOptions.reportBeacon,
213
223
  ...windowOptions?.reportBeacon
214
224
  },
215
- spinner: {
216
- ...defaultOptions.spinner,
217
- ...windowOptions?.spinner
225
+ staticAssets: {
226
+ ...defaultOptions.staticAssets,
227
+ ...windowOptions?.staticAssets
218
228
  }
219
229
  };
220
230
  };
@@ -231,8 +241,19 @@ var ForceRetryError = class extends Error {
231
241
  // src/common/lastReloadTime.ts
232
242
  var STORAGE_KEY = "__spa_guard_last_reload_timestamp__";
233
243
  var RESET_INFO_KEY = "__spa_guard_last_retry_reset__";
234
- var inMemoryStorage = null;
235
- var inMemoryResetInfo = null;
244
+ if (globalThis.window && !globalThis.window[inMemoryLastReloadKey]) {
245
+ globalThis.window[inMemoryLastReloadKey] = {
246
+ resetInfo: null,
247
+ storage: null
248
+ };
249
+ }
250
+ var getInMemoryState = () => {
251
+ const w = globalThis.window;
252
+ if (!w) {
253
+ return { resetInfo: null, storage: null };
254
+ }
255
+ return w[inMemoryLastReloadKey] ?? (w[inMemoryLastReloadKey] = { resetInfo: null, storage: null });
256
+ };
236
257
  var hasSessionStorage = () => {
237
258
  try {
238
259
  return globalThis.window !== void 0 && typeof sessionStorage !== "undefined";
@@ -250,10 +271,10 @@ var setLastReloadTime = (retryId, attemptNumber) => {
250
271
  try {
251
272
  sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data));
252
273
  } catch {
253
- inMemoryStorage = data;
274
+ getInMemoryState().storage = data;
254
275
  }
255
276
  } else {
256
- inMemoryStorage = data;
277
+ getInMemoryState().storage = data;
257
278
  }
258
279
  };
259
280
  var getLastReloadTime = () => {
@@ -264,10 +285,14 @@ var getLastReloadTime = () => {
264
285
  return JSON.parse(stored);
265
286
  }
266
287
  } catch {
267
- return inMemoryStorage;
288
+ try {
289
+ sessionStorage.removeItem(STORAGE_KEY);
290
+ } catch {
291
+ }
292
+ return getInMemoryState().storage;
268
293
  }
269
294
  }
270
- return inMemoryStorage;
295
+ return getInMemoryState().storage;
271
296
  };
272
297
  var clearLastReloadTime = () => {
273
298
  if (hasSessionStorage()) {
@@ -276,7 +301,7 @@ var clearLastReloadTime = () => {
276
301
  } catch {
277
302
  }
278
303
  }
279
- inMemoryStorage = null;
304
+ getInMemoryState().storage = null;
280
305
  };
281
306
  var shouldResetRetryCycle = (retryState, reloadDelays, minTimeBetweenResets = 5e3) => {
282
307
  if (retryState.retryAttempt === 0) {
@@ -311,10 +336,10 @@ var setLastRetryResetInfo = (previousRetryId) => {
311
336
  try {
312
337
  sessionStorage.setItem(RESET_INFO_KEY, JSON.stringify(data));
313
338
  } catch {
314
- inMemoryResetInfo = data;
339
+ getInMemoryState().resetInfo = data;
315
340
  }
316
341
  } else {
317
- inMemoryResetInfo = data;
342
+ getInMemoryState().resetInfo = data;
318
343
  }
319
344
  };
320
345
  var getLastRetryResetInfo = () => {
@@ -325,10 +350,14 @@ var getLastRetryResetInfo = () => {
325
350
  return JSON.parse(stored);
326
351
  }
327
352
  } catch {
328
- return inMemoryResetInfo;
353
+ try {
354
+ sessionStorage.removeItem(RESET_INFO_KEY);
355
+ } catch {
356
+ }
357
+ return getInMemoryState().resetInfo;
329
358
  }
330
359
  }
331
- return inMemoryResetInfo;
360
+ return getInMemoryState().resetInfo;
332
361
  };
333
362
 
334
363
  // src/common/retryState.ts
@@ -421,6 +450,7 @@ export {
421
450
  RETRY_ID_PARAM,
422
451
  RETRY_ATTEMPT_PARAM,
423
452
  versionCheckStateWindowKey,
453
+ reloadScheduledKey,
424
454
  debugSyncErrorEventType,
425
455
  setLogger,
426
456
  getLogger,
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  showFallbackUI
3
- } from "./chunk-CSN7MQGX.js";
3
+ } from "./chunk-KAH6PVTJ.js";
4
4
  import {
5
5
  ForceRetryError,
6
6
  debugSyncErrorEventType,
7
7
  emitEvent,
8
8
  getOptions
9
- } from "./chunk-X6E7KUDK.js";
9
+ } from "./chunk-67WBMNUS.js";
10
10
 
11
11
  // src/runtime/debug/errorDispatchers.ts
12
12
  function dispatchAsyncRuntimeError() {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  defaultSpinnerHtml,
3
3
  getOptions
4
- } from "./chunk-X6E7KUDK.js";
4
+ } from "./chunk-67WBMNUS.js";
5
5
 
6
6
  // src/common/parseVersion.ts
7
7
  function extractVersionFromHtml(html) {
@@ -37,11 +37,11 @@ function dismissSpinner() {
37
37
  }
38
38
  function getSpinnerHtml(backgroundOverride) {
39
39
  const opts = getOptions();
40
- if (opts.spinner?.disabled) {
40
+ if (opts.html?.spinner?.disabled) {
41
41
  return "";
42
42
  }
43
- const spinnerContent = opts.spinner?.content ?? defaultSpinnerSvg;
44
- const bg = backgroundOverride ?? opts.spinner?.background ?? "#fff";
43
+ const spinnerContent = opts.html?.spinner?.content ?? defaultSpinnerSvg;
44
+ const bg = backgroundOverride ?? opts.html?.spinner?.background ?? "#fff";
45
45
  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
46
  }
47
47
  function showSpinner(options) {
@@ -50,7 +50,7 @@ function showSpinner(options) {
50
50
  };
51
51
  }
52
52
  const opts = getOptions();
53
- if (opts.spinner?.disabled) {
53
+ if (opts.html?.spinner?.disabled) {
54
54
  return () => {
55
55
  };
56
56
  }
@@ -3,8 +3,9 @@ import {
3
3
  sendBeacon,
4
4
  shouldForceRetry,
5
5
  shouldIgnoreMessages
6
- } from "./chunk-CSN7MQGX.js";
6
+ } from "./chunk-KAH6PVTJ.js";
7
7
  import {
8
+ emitEvent,
8
9
  getLogger,
9
10
  getOptions,
10
11
  getRetryInfoForBeacon,
@@ -13,7 +14,7 @@ import {
13
14
  markInitialized,
14
15
  setLogger,
15
16
  updateRetryStateInUrl
16
- } from "./chunk-X6E7KUDK.js";
17
+ } from "./chunk-67WBMNUS.js";
17
18
 
18
19
  // src/common/isChunkError.ts
19
20
  var isChunkError = (error) => {
@@ -158,6 +159,59 @@ var extractOwnProperties = (obj) => {
158
159
  return props;
159
160
  };
160
161
 
162
+ // src/common/isStaticAssetError.ts
163
+ var HASHED_ASSET_RE = /[-._][a-zA-Z0-9]{6,}\.(js|mjs|css)$/i;
164
+ var isHashedAssetUrl = (url) => {
165
+ try {
166
+ const pathname = new URL(url).pathname;
167
+ return HASHED_ASSET_RE.test(pathname);
168
+ } catch {
169
+ return HASHED_ASSET_RE.test(url);
170
+ }
171
+ };
172
+ var isStaticAssetError = (event) => {
173
+ const target = event.target;
174
+ if (target instanceof HTMLScriptElement) {
175
+ return isHashedAssetUrl(target.src);
176
+ }
177
+ if (target instanceof HTMLLinkElement) {
178
+ return isHashedAssetUrl(target.href);
179
+ }
180
+ return false;
181
+ };
182
+ var isLikely404 = (timeSinceNavMs = typeof performance === "undefined" ? 0 : performance.now()) => {
183
+ return timeSinceNavMs > 3e4;
184
+ };
185
+ var getAssetUrl = (event) => {
186
+ const target = event.target;
187
+ if (target instanceof HTMLScriptElement) {
188
+ return target.src;
189
+ }
190
+ if (target instanceof HTMLLinkElement) {
191
+ return target.href;
192
+ }
193
+ return "";
194
+ };
195
+
196
+ // src/common/staticAssetRecovery.ts
197
+ var recoveryTimer = null;
198
+ var failedAssets = /* @__PURE__ */ new Set();
199
+ var handleStaticAssetFailure = (url) => {
200
+ failedAssets.add(url);
201
+ if (recoveryTimer !== null) {
202
+ return;
203
+ }
204
+ const options = getOptions();
205
+ const delay = options.staticAssets?.recoveryDelay ?? 500;
206
+ recoveryTimer = setTimeout(() => {
207
+ const assets = [...failedAssets];
208
+ failedAssets = /* @__PURE__ */ new Set();
209
+ recoveryTimer = null;
210
+ const error = new Error(`Static asset load failed: ${assets.join(", ")}`);
211
+ attemptReload(error, { cacheBust: true });
212
+ }, delay);
213
+ };
214
+
161
215
  // src/common/listen/internal.ts
162
216
  var listenInternal = (serializeError2, logger) => {
163
217
  if (logger) {
@@ -192,6 +246,15 @@ var listenInternal = (serializeError2, logger) => {
192
246
  attemptReload(event.error ?? event);
193
247
  return;
194
248
  }
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
+ }
195
258
  const serialized = serializeError2(event);
196
259
  sendBeacon({
197
260
  errorMessage: event.message,
@@ -265,10 +328,12 @@ var eventLogConfig = {
265
328
  "fallback-ui-shown": "warn",
266
329
  "lazy-retry-attempt": "warn",
267
330
  "lazy-retry-exhausted": "error",
331
+ "lazy-retry-start": "log",
268
332
  "lazy-retry-success": "log",
269
333
  "retry-attempt": "warn",
270
334
  "retry-exhausted": "error",
271
- "retry-reset": "log"
335
+ "retry-reset": "log",
336
+ "static-asset-load-failed": "error"
272
337
  };
273
338
  var formatEvent = (event) => {
274
339
  switch (event.name) {
@@ -284,8 +349,12 @@ var formatEvent = (event) => {
284
349
  case "lazy-retry-exhausted": {
285
350
  return `${PREFIX} lazy-retry-exhausted: ${event.totalAttempts} attempts, willReload=${event.willReload}`;
286
351
  }
352
+ case "lazy-retry-start": {
353
+ return `${PREFIX} lazy-retry-start: totalAttempts=${event.totalAttempts}`;
354
+ }
287
355
  case "lazy-retry-success": {
288
- return `${PREFIX} lazy-retry-success: succeeded on attempt ${event.attempt}`;
356
+ const timePart = event.totalTime === void 0 ? "" : `, totalTime=${event.totalTime}ms`;
357
+ return `${PREFIX} lazy-retry-success: succeeded on attempt ${event.attempt}${timePart}`;
289
358
  }
290
359
  case "retry-attempt": {
291
360
  return `${PREFIX} retry-attempt: attempt ${event.attempt} in ${event.delay}ms (retryId: ${event.retryId})`;
@@ -296,6 +365,9 @@ var formatEvent = (event) => {
296
365
  case "retry-reset": {
297
366
  return `${PREFIX} retry-reset: ${event.timeSinceReload}ms since last reload (retryId: ${event.previousRetryId})`;
298
367
  }
368
+ case "static-asset-load-failed": {
369
+ return `${PREFIX} static-asset-load-failed: ${event.url}`;
370
+ }
299
371
  }
300
372
  };
301
373
  var createLogger = () => ({
@@ -329,7 +401,7 @@ var createLogger = () => ({
329
401
  logEvent(event) {
330
402
  const level = eventLogConfig[event.name];
331
403
  const message = formatEvent(event);
332
- if (event.name === "chunk-error") {
404
+ if (event.name === "chunk-error" || event.name === "lazy-retry-attempt" || event.name === "lazy-retry-exhausted") {
333
405
  console[level](message, event.error);
334
406
  } else {
335
407
  console[level](message);
@@ -15,10 +15,11 @@ import {
15
15
  getRetryAttemptFromUrl,
16
16
  getRetryStateFromUrl,
17
17
  isDefaultRetryEnabled,
18
+ reloadScheduledKey,
18
19
  setLastReloadTime,
19
20
  setLastRetryResetInfo,
20
21
  shouldResetRetryCycle
21
- } from "./chunk-X6E7KUDK.js";
22
+ } from "./chunk-67WBMNUS.js";
22
23
 
23
24
  // src/common/shouldIgnore.ts
24
25
  var shouldIgnoreMessages = (messages) => {
@@ -81,13 +82,22 @@ var buildReloadUrlAttemptOnly = (retryAttempt) => {
81
82
  url.searchParams.set(RETRY_ATTEMPT_PARAM, String(retryAttempt));
82
83
  return url.toString();
83
84
  };
84
- var reloadScheduled = false;
85
- var attemptReload = (error) => {
86
- if (reloadScheduled) {
85
+ if (globalThis.window && !globalThis.window[reloadScheduledKey]) {
86
+ globalThis.window[reloadScheduledKey] = { scheduled: false };
87
+ }
88
+ var getReloadState = () => {
89
+ if (globalThis.window === void 0) {
90
+ return { scheduled: false };
91
+ }
92
+ return globalThis.window[reloadScheduledKey] ?? (globalThis.window[reloadScheduledKey] = { scheduled: false });
93
+ };
94
+ var attemptReload = (error, opts) => {
95
+ const reloadState = getReloadState();
96
+ if (reloadState.scheduled) {
87
97
  getLogger()?.reloadAlreadyScheduled(error);
88
98
  return;
89
99
  }
90
- reloadScheduled = true;
100
+ reloadState.scheduled = true;
91
101
  try {
92
102
  const options = getOptions();
93
103
  const reloadDelays = options.reloadDelays ?? [1e3, 2e3, 5e3];
@@ -111,7 +121,7 @@ var attemptReload = (error) => {
111
121
  name: "chunk-error"
112
122
  });
113
123
  if (!retryEnabled) {
114
- reloadScheduled = false;
124
+ reloadState.scheduled = false;
115
125
  return;
116
126
  }
117
127
  if (enableRetryReset && retryState && retryState.retryAttempt > 0 && shouldResetRetryCycle(retryState, reloadDelays, minTimeBetweenResets)) {
@@ -138,7 +148,7 @@ var attemptReload = (error) => {
138
148
  if (!shouldIgnoreMessages([errorMsg2])) {
139
149
  getLogger()?.fallbackAlreadyShown(error);
140
150
  }
141
- reloadScheduled = false;
151
+ reloadState.scheduled = false;
142
152
  showFallbackUI();
143
153
  return;
144
154
  }
@@ -166,7 +176,7 @@ var attemptReload = (error) => {
166
176
  if (!useRetryId) {
167
177
  clearRetryAttemptFromUrl();
168
178
  }
169
- reloadScheduled = false;
179
+ reloadState.scheduled = false;
170
180
  showFallbackUI();
171
181
  return;
172
182
  }
@@ -188,15 +198,17 @@ var attemptReload = (error) => {
188
198
  if (useRetryId && enableRetryReset) {
189
199
  setLastReloadTime(retryId, nextAttempt);
190
200
  }
191
- if (useRetryId) {
192
- const reloadUrl = buildReloadUrl(retryId, nextAttempt);
193
- globalThis.window.location.href = reloadUrl;
194
- } else {
195
- globalThis.window.location.href = buildReloadUrlAttemptOnly(nextAttempt);
201
+ let reloadUrl;
202
+ reloadUrl = useRetryId ? buildReloadUrl(retryId, nextAttempt) : buildReloadUrlAttemptOnly(nextAttempt);
203
+ if (opts?.cacheBust) {
204
+ const url = new URL(reloadUrl);
205
+ url.searchParams.set("spaGuardCacheBust", String(Date.now()));
206
+ reloadUrl = url.toString();
196
207
  }
208
+ globalThis.window.location.href = reloadUrl;
197
209
  }, delay);
198
210
  } catch {
199
- reloadScheduled = false;
211
+ reloadState.scheduled = false;
200
212
  }
201
213
  };
202
214
  var showLoadingUI = (attempt) => {
@@ -215,10 +227,10 @@ var showLoadingUI = (attempt) => {
215
227
  container.innerHTML = loadingHtml;
216
228
  const spinnerEl = container.querySelector("[data-spa-guard-spinner]");
217
229
  if (spinnerEl) {
218
- if (options.spinner?.disabled) {
230
+ if (options.html?.spinner?.disabled) {
219
231
  spinnerEl.remove();
220
- } else if (options.spinner?.content) {
221
- spinnerEl.innerHTML = options.spinner.content;
232
+ } else if (options.html?.spinner?.content) {
233
+ spinnerEl.innerHTML = options.html.spinner.content;
222
234
  }
223
235
  }
224
236
  const retryingSection = container.querySelector(
@@ -3,7 +3,7 @@ import {
3
3
  getRetryAttemptFromUrl,
4
4
  getRetryStateFromUrl,
5
5
  subscribe
6
- } from "./chunk-X6E7KUDK.js";
6
+ } from "./chunk-67WBMNUS.js";
7
7
 
8
8
  // src/runtime/state.ts
9
9
  var getInitialStateFromUrl = () => {
@@ -6,4 +6,6 @@ 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
8
  export declare const versionCheckStateWindowKey: unique symbol;
9
+ export declare const reloadScheduledKey: unique symbol;
10
+ export declare const inMemoryLastReloadKey: unique symbol;
9
11
  export declare const debugSyncErrorEventType = "spa-guard:debug-sync-error";
@@ -13,6 +13,8 @@ export type SPAGuardEvent = (SPAGuardEventChunkError & {
13
13
  name: "lazy-retry-attempt";
14
14
  }) | (SPAGuardEventLazyRetryExhausted & {
15
15
  name: "lazy-retry-exhausted";
16
+ }) | (SPAGuardEventLazyRetryStart & {
17
+ name: "lazy-retry-start";
16
18
  }) | (SPAGuardEventLazyRetrySuccess & {
17
19
  name: "lazy-retry-success";
18
20
  }) | (SPAGuardEventRetryAttempt & {
@@ -21,6 +23,8 @@ export type SPAGuardEvent = (SPAGuardEventChunkError & {
21
23
  name: "retry-exhausted";
22
24
  }) | (SPAGuardEventRetryReset & {
23
25
  name: "retry-reset";
26
+ }) | (SPAGuardEventStaticAssetLoadFailed & {
27
+ name: "static-asset-load-failed";
24
28
  });
25
29
  export interface SPAGuardEventChunkError {
26
30
  error: unknown;
@@ -36,23 +40,35 @@ export interface SPAGuardEventLazyRetryAttempt {
36
40
  attempt: number;
37
41
  /** Delay in milliseconds before this retry attempt. */
38
42
  delay: number;
43
+ /** The error that caused the previous attempt to fail. */
44
+ error?: unknown;
39
45
  name: "lazy-retry-attempt";
40
46
  /** Total number of attempts including the initial try (delays.length + 1). */
41
47
  totalAttempts: number;
42
48
  }
43
49
  /** Emitted when all module-level retry attempts are exhausted. */
44
50
  export interface SPAGuardEventLazyRetryExhausted {
51
+ /** The final error after all attempts failed. */
52
+ error: unknown;
45
53
  name: "lazy-retry-exhausted";
46
54
  /** Total number of attempts that were made (delays.length + 1). */
47
55
  totalAttempts: number;
48
56
  /** Whether attemptReload() will be called after this event. */
49
57
  willReload: boolean;
50
58
  }
59
+ /** Emitted once before the first import attempt, when retries are configured. */
60
+ export interface SPAGuardEventLazyRetryStart {
61
+ name: "lazy-retry-start";
62
+ /** Total number of attempts that will be made (delays.length + 1). */
63
+ totalAttempts: number;
64
+ }
51
65
  /** Emitted when a module loads successfully after one or more retry attempts. */
52
66
  export interface SPAGuardEventLazyRetrySuccess {
53
67
  /** 1-based retry number on which the import succeeded (1 = first retry). */
54
68
  attempt: number;
55
69
  name: "lazy-retry-success";
70
+ /** Total time in milliseconds from first attempt to success. */
71
+ totalTime?: number;
56
72
  }
57
73
  export interface SPAGuardEventRetryAttempt {
58
74
  attempt: number;
@@ -71,5 +87,11 @@ export interface SPAGuardEventRetryReset {
71
87
  previousRetryId: string;
72
88
  timeSinceReload: number;
73
89
  }
90
+ /** Emitted when a hashed static asset (script/link) fails to load, likely due to a stale deployment. */
91
+ export interface SPAGuardEventStaticAssetLoadFailed {
92
+ name: "static-asset-load-failed";
93
+ /** The URL of the asset that failed to load. */
94
+ url: string;
95
+ }
74
96
  export type SubscribeFn = (event: SPAGuardEvent) => void;
75
97
  export type UnsubscribeFn = () => void;
@@ -2,8 +2,8 @@ import {
2
2
  createLogger,
3
3
  listenInternal,
4
4
  serializeError
5
- } from "../chunk-ZVYB2746.js";
6
- import "../chunk-CSN7MQGX.js";
5
+ } from "../chunk-FRCJOMRP.js";
6
+ import "../chunk-KAH6PVTJ.js";
7
7
  import {
8
8
  ForceRetryError,
9
9
  disableDefaultRetry,
@@ -12,7 +12,7 @@ import {
12
12
  isDefaultRetryEnabled,
13
13
  options_exports,
14
14
  subscribe
15
- } from "../chunk-X6E7KUDK.js";
15
+ } from "../chunk-67WBMNUS.js";
16
16
  import {
17
17
  __export
18
18
  } from "../chunk-MLKGABMK.js";
@@ -0,0 +1,3 @@
1
+ export declare const isStaticAssetError: (event: Event) => boolean;
2
+ export declare const isLikely404: (timeSinceNavMs?: number) => boolean;
3
+ export declare const getAssetUrl: (event: Event) => string;
@@ -95,6 +95,30 @@ export interface Options {
95
95
  /** Custom HTML to display during the loading/retrying state */
96
96
  content?: string;
97
97
  };
98
+ /**
99
+ * Spinner overlay configuration.
100
+ * Controls the full-page loading spinner injected by the Vite plugin
101
+ * and available via `showSpinner()`/`dismissSpinner()` at runtime.
102
+ */
103
+ spinner?: {
104
+ /**
105
+ * Overlay background color.
106
+ * Used as CSS variable fallback: var(--spa-guard-spinner-bg, <this value>).
107
+ * @default '#fff'
108
+ */
109
+ background?: string;
110
+ /**
111
+ * Custom spinner HTML (the spinner element only, no container/overlay).
112
+ * If not provided, uses the default SVG spinner.
113
+ */
114
+ content?: string;
115
+ /**
116
+ * Disable spinner entirely.
117
+ * No injection into body, showSpinner() is a no-op, Spinner returns null.
118
+ * @default false
119
+ */
120
+ disabled?: boolean;
121
+ };
98
122
  };
99
123
  /**
100
124
  * Options for the lazyWithRetry module-level retry logic.
@@ -130,28 +154,22 @@ export interface Options {
130
154
  endpoint?: string;
131
155
  };
132
156
  /**
133
- * Spinner overlay configuration.
134
- * Controls the full-page loading spinner injected by the Vite plugin
135
- * and available via `showSpinner()`/`dismissSpinner()` at runtime.
157
+ * Configuration for automatic recovery from static asset 404 errors
158
+ * caused by deployment version mismatches.
136
159
  */
137
- spinner?: {
160
+ staticAssets?: {
138
161
  /**
139
- * Overlay background color.
140
- * Used as CSS variable fallback: var(--spa-guard-spinner-bg, <this value>).
141
- * @default '#fff'
142
- */
143
- background?: string;
144
- /**
145
- * Custom spinner HTML (the spinner element only, no container/overlay).
146
- * If not provided, uses the default SVG spinner.
162
+ * Automatically trigger a cache-busting reload when a hashed static asset
163
+ * fails to load and the page has been open long enough to suggest a stale deployment.
164
+ * @default true
147
165
  */
148
- content?: string;
166
+ autoRecover?: boolean;
149
167
  /**
150
- * Disable spinner entirely.
151
- * No injection into body, showSpinner() is a no-op, Spinner returns null.
152
- * @default false
168
+ * Milliseconds to wait after the first failed asset before triggering the reload.
169
+ * Allows collecting multiple concurrent failures into a single reload.
170
+ * @default 500
153
171
  */
154
- disabled?: boolean;
172
+ recoveryDelay?: number;
155
173
  };
156
174
  /** @default true */
157
175
  useRetryId?: boolean;
@@ -1,4 +1,6 @@
1
1
  /** @internal */
2
2
  export declare const resetReloadScheduled: () => void;
3
- export declare const attemptReload: (error: unknown) => void;
3
+ export declare const attemptReload: (error: unknown, opts?: {
4
+ cacheBust?: boolean;
5
+ }) => void;
4
6
  export declare const showFallbackUI: () => void;
@@ -0,0 +1,2 @@
1
+ export declare const handleStaticAssetFailure: (url: string) => void;
2
+ export declare const resetStaticAssetRecovery: () => void;
@@ -7,14 +7,14 @@ import {
7
7
  dispatchRetryExhausted,
8
8
  dispatchSyncRuntimeError,
9
9
  dispatchUnhandledRejection
10
- } from "../../chunk-XFILAQ7P.js";
11
- import "../../chunk-CSN7MQGX.js";
10
+ } from "../../chunk-7KNE2UWX.js";
11
+ import "../../chunk-KAH6PVTJ.js";
12
12
  import {
13
13
  subscribeToState
14
- } from "../../chunk-T72DERME.js";
14
+ } from "../../chunk-V2OOMVZK.js";
15
15
  import {
16
16
  subscribe
17
- } from "../../chunk-X6E7KUDK.js";
17
+ } from "../../chunk-67WBMNUS.js";
18
18
  import "../../chunk-MLKGABMK.js";
19
19
 
20
20
  // src/runtime/debug/index.ts
@@ -3,18 +3,18 @@ import {
3
3
  extractVersionFromHtml,
4
4
  getSpinnerHtml,
5
5
  showSpinner
6
- } from "../chunk-Z75UPJWV.js";
6
+ } from "../chunk-7PDOUY6W.js";
7
7
  import {
8
8
  getState,
9
9
  subscribeToState
10
- } from "../chunk-T72DERME.js";
10
+ } from "../chunk-V2OOMVZK.js";
11
11
  import {
12
12
  ForceRetryError,
13
13
  getLogger,
14
14
  getOptions,
15
15
  setTranslations,
16
16
  versionCheckStateWindowKey
17
- } from "../chunk-X6E7KUDK.js";
17
+ } from "../chunk-67WBMNUS.js";
18
18
  import "../chunk-MLKGABMK.js";
19
19
 
20
20
  // src/common/checkVersion.ts
@@ -33,7 +33,10 @@ if (globalThis.window && !globalThis.window[versionCheckStateWindowKey]) {
33
33
  globalThis.window[versionCheckStateWindowKey] = createInitialState();
34
34
  }
35
35
  var getState2 = () => {
36
- return globalThis.window?.[versionCheckStateWindowKey] ?? (globalThis.window[versionCheckStateWindowKey] = createInitialState());
36
+ if (globalThis.window === void 0) {
37
+ return createInitialState();
38
+ }
39
+ return globalThis.window[versionCheckStateWindowKey] ?? (globalThis.window[versionCheckStateWindowKey] = createInitialState());
37
40
  };
38
41
  var fetchJsonVersion = async () => {
39
42
  const endpoint = getOptions().checkVersion?.endpoint;
@@ -1,9 +1,13 @@
1
1
  export interface BeaconSchema {
2
2
  appName?: string;
3
+ errorContext?: string;
3
4
  errorMessage?: string;
5
+ errorType?: string;
4
6
  eventMessage?: string;
5
7
  eventName?: string;
8
+ httpStatus?: number;
6
9
  retryAttempt?: number;
7
10
  retryId?: string;
8
11
  serialized?: string;
12
+ url?: string;
9
13
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ovineko/spa-guard",
3
- "version": "0.0.1-alpha-25",
3
+ "version": "0.0.1-alpha-27",
4
4
  "description": "Chunk load error handling for SPAs — core runtime, error handling, schema, i18n",
5
5
  "keywords": [
6
6
  "spa",