@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 +15 -1
- package/dist/_internal.d.ts +1 -1
- package/dist/_internal.js +11 -7
- package/dist/{chunk-KAH6PVTJ.js → chunk-2BUEJXDB.js} +34 -2
- package/dist/{chunk-7KNE2UWX.js → chunk-3LAQJKWC.js} +13 -3
- package/dist/{chunk-7PDOUY6W.js → chunk-5546JVQV.js} +23 -9
- package/dist/{chunk-FRCJOMRP.js → chunk-CBAJOLBG.js} +85 -24
- package/dist/{chunk-67WBMNUS.js → chunk-LBHLKYQS.js} +11 -2
- package/dist/{chunk-V2OOMVZK.js → chunk-W3TOHCEW.js} +1 -1
- package/dist/common/constants.d.ts +4 -0
- package/dist/common/fallbackState.d.ts +3 -0
- package/dist/common/index.d.ts +1 -0
- package/dist/common/index.js +9 -4
- package/dist/common/isStaticAssetError.d.ts +1 -1
- package/dist/common/spinner.d.ts +1 -0
- package/dist/runtime/debug/errorDispatchers.d.ts +7 -0
- package/dist/runtime/debug/index.js +7 -5
- package/dist/runtime/index.js +50 -30
- package/dist/schema/parse.js +16 -5
- package/package.json +1 -1
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.d.ts
CHANGED
|
@@ -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-
|
|
6
|
+
} from "./chunk-CBAJOLBG.js";
|
|
7
7
|
import {
|
|
8
8
|
SPINNER_ID,
|
|
9
9
|
defaultSpinnerSvg,
|
|
10
|
-
extractVersionFromHtml
|
|
11
|
-
|
|
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-
|
|
21
|
+
} from "./chunk-3LAQJKWC.js";
|
|
21
22
|
import {
|
|
22
23
|
attemptReload,
|
|
23
24
|
sendBeacon,
|
|
24
25
|
shouldForceRetry,
|
|
25
26
|
shouldIgnoreMessages
|
|
26
|
-
} from "./chunk-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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(
|
|
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-
|
|
3
|
+
} from "./chunk-2BUEJXDB.js";
|
|
4
4
|
import {
|
|
5
5
|
ForceRetryError,
|
|
6
6
|
debugSyncErrorEventType,
|
|
7
7
|
emitEvent,
|
|
8
8
|
getOptions
|
|
9
|
-
} from "./chunk-
|
|
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 ?? [
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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-
|
|
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-
|
|
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
|
|
183
|
-
|
|
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
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
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,
|
|
@@ -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;
|
package/dist/common/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/common/index.js
CHANGED
|
@@ -2,8 +2,11 @@ import {
|
|
|
2
2
|
createLogger,
|
|
3
3
|
listenInternal,
|
|
4
4
|
serializeError
|
|
5
|
-
} from "../chunk-
|
|
6
|
-
import
|
|
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-
|
|
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;
|
package/dist/common/spinner.d.ts
CHANGED
|
@@ -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-
|
|
11
|
-
import "../../chunk-
|
|
11
|
+
} from "../../chunk-3LAQJKWC.js";
|
|
12
|
+
import "../../chunk-2BUEJXDB.js";
|
|
12
13
|
import {
|
|
13
14
|
subscribeToState
|
|
14
|
-
} from "../../chunk-
|
|
15
|
+
} from "../../chunk-W3TOHCEW.js";
|
|
15
16
|
import {
|
|
16
17
|
subscribe
|
|
17
|
-
} from "../../chunk-
|
|
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;",
|
package/dist/runtime/index.js
CHANGED
|
@@ -3,18 +3,18 @@ import {
|
|
|
3
3
|
extractVersionFromHtml,
|
|
4
4
|
getSpinnerHtml,
|
|
5
5
|
showSpinner
|
|
6
|
-
} from "../chunk-
|
|
6
|
+
} from "../chunk-5546JVQV.js";
|
|
7
7
|
import {
|
|
8
8
|
getState,
|
|
9
9
|
subscribeToState
|
|
10
|
-
} from "../chunk-
|
|
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-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
264
|
+
if (options.versionCheck) {
|
|
265
|
+
stopVersionCheck();
|
|
266
|
+
}
|
|
247
267
|
};
|
|
248
268
|
};
|
|
249
269
|
export {
|
package/dist/schema/parse.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
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
|
}
|