@jskit-ai/shell-web 0.1.86 → 0.1.88

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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/shell-web",
4
- version: "0.1.86",
4
+ version: "0.1.88",
5
5
  kind: "runtime",
6
6
  description: "Web shell layout runtime with outlet-based placement contributions.",
7
7
  dependsOn: [],
@@ -9,7 +9,8 @@ export default Object.freeze({
9
9
  provides: [
10
10
  "runtime.web-placement",
11
11
  "runtime.web-error",
12
- "runtime.web-async-module-recovery"
12
+ "runtime.web-async-module-recovery",
13
+ "runtime.web-request-recovery"
13
14
  ],
14
15
  requires: []
15
16
  },
@@ -41,6 +42,10 @@ export default Object.freeze({
41
42
  subpath: "./client/error",
42
43
  summary: "Exports default error policy and runtime error reporter hook."
43
44
  },
45
+ {
46
+ subpath: "./client/requestRecovery",
47
+ summary: "Exports request connectivity recovery classification and runtime access for app-caught request failures."
48
+ },
44
49
  {
45
50
  subpath: "./client/bootstrap",
46
51
  summary: "Exports the shared client bootstrap handler registry used to extend /api/bootstrap handling."
@@ -57,6 +62,7 @@ export default Object.freeze({
57
62
  "runtime.web-bootstrap.client",
58
63
  "runtime.web-refresh.client",
59
64
  "runtime.web-async-module-recovery.client",
65
+ "runtime.web-request-recovery.client",
60
66
  "runtime.web-error.client",
61
67
  "runtime.web-error.presentation-store.client"
62
68
  ]
@@ -294,7 +300,7 @@ export default Object.freeze({
294
300
  dependencies: {
295
301
  runtime: {
296
302
  "@mdi/js": "^7.4.47",
297
- "@jskit-ai/kernel": "0.1.87"
303
+ "@jskit-ai/kernel": "0.1.89"
298
304
  },
299
305
  dev: {}
300
306
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/shell-web",
3
- "version": "0.1.86",
3
+ "version": "0.1.88",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -9,6 +9,8 @@
9
9
  "./client": "./src/client/index.js",
10
10
  "./client/error": "./src/client/error/index.js",
11
11
  "./client/placement": "./src/client/placement/index.js",
12
+ "./client/asyncModuleRecovery": "./src/client/asyncModuleRecovery/index.js",
13
+ "./client/requestRecovery": "./src/client/requestRecovery/index.js",
12
14
  "./client/bootstrap": "./src/client/bootstrap/index.js",
13
15
  "./server/support/localLinkItemScaffolds": "./src/server/support/localLinkItemScaffolds.js",
14
16
  "./client/navigation/linkResolver": "./src/client/navigation/linkResolver.js",
@@ -26,7 +28,7 @@
26
28
  },
27
29
  "dependencies": {
28
30
  "@mdi/js": "^7.4.47",
29
- "@jskit-ai/kernel": "0.1.87"
31
+ "@jskit-ai/kernel": "0.1.89"
30
32
  },
31
33
  "peerDependencies": {
32
34
  "pinia": "^3.0.4",
@@ -0,0 +1,4 @@
1
+ export {
2
+ SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY,
3
+ useShellAsyncModuleRecoveryRuntime
4
+ } from "./inject.js";
@@ -0,0 +1,30 @@
1
+ import {
2
+ hasInjectionContext,
3
+ inject
4
+ } from "vue";
5
+
6
+ const SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY =
7
+ "jskit.shell-web.runtime.web-async-module-recovery.client";
8
+
9
+ function isShellAsyncModuleRecoveryRuntime(value) {
10
+ return Boolean(
11
+ value &&
12
+ typeof value.notify === "function" &&
13
+ typeof value.reload === "function"
14
+ );
15
+ }
16
+
17
+ function useShellAsyncModuleRecoveryRuntime() {
18
+ if (!hasInjectionContext()) {
19
+ return null;
20
+ }
21
+
22
+ const runtime = inject(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY, null);
23
+ return isShellAsyncModuleRecoveryRuntime(runtime) ? runtime : null;
24
+ }
25
+
26
+ export {
27
+ SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY,
28
+ isShellAsyncModuleRecoveryRuntime,
29
+ useShellAsyncModuleRecoveryRuntime
30
+ };
@@ -17,6 +17,16 @@ export { default as ShellTabLinkItem } from "./components/ShellTabLinkItem.vue";
17
17
  export { useShellLayoutState } from "./composables/useShellLayoutState.js";
18
18
  export { useShellLayoutStore } from "./stores/useShellLayoutStore.js";
19
19
  export { useShellErrorPresentationStore } from "./stores/useShellErrorPresentationStore.js";
20
+ export {
21
+ SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY,
22
+ useShellAsyncModuleRecoveryRuntime
23
+ } from "./asyncModuleRecovery/index.js";
24
+ export {
25
+ SHELL_REQUEST_RECOVERY_RUNTIME_KEY,
26
+ isRecoverableRequestError,
27
+ requestRecoveryMessage,
28
+ useShellRequestRecoveryRuntime
29
+ } from "./requestRecovery/index.js";
20
30
  export {
21
31
  BOOTSTRAP_PAYLOAD_HANDLER_TAG,
22
32
  registerBootstrapPayloadHandler,
@@ -10,6 +10,15 @@ import {
10
10
  isMissingDynamicModule,
11
11
  notifyDynamicImportFailure
12
12
  } from "./appModuleLoadFailure.js";
13
+ import {
14
+ SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY
15
+ } from "../asyncModuleRecovery/inject.js";
16
+ import {
17
+ SHELL_REQUEST_RECOVERY_RUNTIME_KEY
18
+ } from "../requestRecovery/inject.js";
19
+ import {
20
+ createShellRequestRecoveryRuntime
21
+ } from "../requestRecovery/runtime.js";
13
22
  import {
14
23
  isRecord
15
24
  } from "@jskit-ai/kernel/shared/support";
@@ -574,6 +583,12 @@ class ShellWebClientProvider {
574
583
  logger
575
584
  })
576
585
  );
586
+ app.singleton("runtime.web-request-recovery.client", (scope) =>
587
+ createShellRequestRecoveryRuntime({
588
+ app: scope,
589
+ logger
590
+ })
591
+ );
577
592
  app.singleton("runtime.web-error.presentation-store.client", () => createErrorPresentationStore());
578
593
  app.singleton("runtime.web-error.client", (scope) =>
579
594
  createErrorRuntime({
@@ -595,6 +610,7 @@ class ShellWebClientProvider {
595
610
  const logger = createSharedProviderLogger(isRecord(app) ? app : null);
596
611
  const errorRuntime = app.make("runtime.web-error.client");
597
612
  const asyncModuleRecoveryRuntime = app.make("runtime.web-async-module-recovery.client");
613
+ const requestRecoveryRuntime = app.make("runtime.web-request-recovery.client");
598
614
  asyncModuleRecoveryRuntime.install();
599
615
 
600
616
  const placementRuntime = app.make("runtime.web-placement.client");
@@ -629,6 +645,7 @@ class ShellWebClientProvider {
629
645
 
630
646
  const errorConfig = await loadAppErrorConfig(logger, errorRuntime, asyncModuleRecoveryRuntime);
631
647
  applyAppErrorConfig(errorRuntime, errorConfig);
648
+ requestRecoveryRuntime.install();
632
649
 
633
650
  const bootstrapRuntime = app.make("runtime.web-bootstrap.client");
634
651
  if (bootstrapRuntime && typeof bootstrapRuntime.initialize === "function") {
@@ -654,7 +671,8 @@ class ShellWebClientProvider {
654
671
  vueApp.provide("jskit.shell-web.runtime.web-placement.client", placementRuntime);
655
672
  vueApp.provide("jskit.shell-web.runtime.web-refresh.client", refreshRuntime);
656
673
  vueApp.provide("jskit.shell-web.runtime.web-error.client", errorRuntime);
657
- vueApp.provide("jskit.shell-web.runtime.web-async-module-recovery.client", asyncModuleRecoveryRuntime);
674
+ vueApp.provide(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY, asyncModuleRecoveryRuntime);
675
+ vueApp.provide(SHELL_REQUEST_RECOVERY_RUNTIME_KEY, requestRecoveryRuntime);
658
676
  vueApp.provide(
659
677
  "jskit.shell-web.runtime.web-error.presentation-store.client",
660
678
  errorPresentationStore
@@ -0,0 +1,8 @@
1
+ export {
2
+ SHELL_REQUEST_RECOVERY_RUNTIME_KEY,
3
+ useShellRequestRecoveryRuntime
4
+ } from "./inject.js";
5
+ export {
6
+ isRecoverableRequestError,
7
+ requestRecoveryMessage
8
+ } from "./runtime.js";
@@ -0,0 +1,30 @@
1
+ import {
2
+ hasInjectionContext,
3
+ inject
4
+ } from "vue";
5
+
6
+ const SHELL_REQUEST_RECOVERY_RUNTIME_KEY =
7
+ "jskit.shell-web.runtime.web-request-recovery.client";
8
+
9
+ function isShellRequestRecoveryRuntime(value) {
10
+ return Boolean(
11
+ value &&
12
+ typeof value.report === "function" &&
13
+ typeof value.reload === "function"
14
+ );
15
+ }
16
+
17
+ function useShellRequestRecoveryRuntime() {
18
+ if (!hasInjectionContext()) {
19
+ return null;
20
+ }
21
+
22
+ const runtime = inject(SHELL_REQUEST_RECOVERY_RUNTIME_KEY, null);
23
+ return isShellRequestRecoveryRuntime(runtime) ? runtime : null;
24
+ }
25
+
26
+ export {
27
+ SHELL_REQUEST_RECOVERY_RUNTIME_KEY,
28
+ isShellRequestRecoveryRuntime,
29
+ useShellRequestRecoveryRuntime
30
+ };
@@ -0,0 +1,446 @@
1
+ import { guardedReloadApp } from "@jskit-ai/kernel/client/asyncModuleRecovery";
2
+ import { isRecord } from "@jskit-ai/kernel/shared/support";
3
+ import { createProviderLogger as createSharedProviderLogger } from "@jskit-ai/kernel/shared/support/providerLogger";
4
+
5
+ const REQUEST_RECOVERY_RELOAD_FAILURE_MESSAGE =
6
+ "The app cannot reload because the app server is not reachable. Restart the server, then click Reload.";
7
+
8
+ const RECOVERABLE_REQUEST_ERROR_MESSAGES = Object.freeze([
9
+ /Failed to fetch/iu,
10
+ /Load failed/iu,
11
+ /Network request failed/iu,
12
+ /NetworkError when attempting to fetch resource/iu,
13
+ /The Internet connection appears to be offline/iu
14
+ ]);
15
+
16
+ const RECOVERABLE_REQUEST_ERROR_CODES = Object.freeze(new Set([
17
+ "EAI_AGAIN",
18
+ "ECONNABORTED",
19
+ "ECONNREFUSED",
20
+ "ECONNRESET",
21
+ "ENETDOWN",
22
+ "ENETUNREACH",
23
+ "ENOTFOUND",
24
+ "ERR_NETWORK",
25
+ "ETIMEDOUT"
26
+ ]));
27
+
28
+ const CANCELED_REQUEST_ERROR_CODES = Object.freeze(new Set([
29
+ "ABORT_ERR",
30
+ "ERR_ABORTED",
31
+ "ERR_CANCELED"
32
+ ]));
33
+
34
+ function normalizeText(value, fallback = "") {
35
+ const normalized = String(value || "").trim();
36
+ return normalized || String(fallback || "").trim();
37
+ }
38
+
39
+ function errorMessage(error = null) {
40
+ return normalizeText(error?.message || error?.cause?.message || error);
41
+ }
42
+
43
+ function errorCode(error = null) {
44
+ return normalizeText(error?.code || error?.cause?.code).toUpperCase();
45
+ }
46
+
47
+ function errorName(error = null) {
48
+ return normalizeText(error?.name || error?.cause?.name);
49
+ }
50
+
51
+ function normalizeRequestErrorStatus(error = null) {
52
+ if (!isRecord(error)) {
53
+ return null;
54
+ }
55
+
56
+ const hasStatus = Object.prototype.hasOwnProperty.call(error, "status");
57
+ const hasStatusCode = Object.prototype.hasOwnProperty.call(error, "statusCode");
58
+ if (!hasStatus && !hasStatusCode) {
59
+ return null;
60
+ }
61
+
62
+ const status = Number(hasStatus ? error.status : error.statusCode);
63
+ return Number.isInteger(status) ? status : null;
64
+ }
65
+
66
+ function isCanceledRequestError(error = null) {
67
+ const name = errorName(error).toLowerCase();
68
+ if (name === "aborterror" || name === "cancelederror") {
69
+ return true;
70
+ }
71
+ return CANCELED_REQUEST_ERROR_CODES.has(errorCode(error));
72
+ }
73
+
74
+ function isRecoverableRequestError(error = null) {
75
+ if (!error || isCanceledRequestError(error)) {
76
+ return false;
77
+ }
78
+
79
+ const status = normalizeRequestErrorStatus(error);
80
+ if (status === 0) {
81
+ return true;
82
+ }
83
+
84
+ if (RECOVERABLE_REQUEST_ERROR_CODES.has(errorCode(error))) {
85
+ return true;
86
+ }
87
+
88
+ const message = errorMessage(error);
89
+ return Boolean(
90
+ message &&
91
+ RECOVERABLE_REQUEST_ERROR_MESSAGES.some((pattern) => pattern.test(message))
92
+ );
93
+ }
94
+
95
+ function requestRecoveryMessage(error = null, {
96
+ label = "Request",
97
+ message = ""
98
+ } = {}) {
99
+ const explicitMessage = normalizeText(message);
100
+ if (explicitMessage) {
101
+ return explicitMessage;
102
+ }
103
+
104
+ const requestLabel = normalizeText(label, "Request");
105
+ if (requestLabel === "Request") {
106
+ return "The app could not reach the server or network. Check the connection and try again.";
107
+ }
108
+ return `${requestLabel} could not reach the server or network. Check the connection and try again.`;
109
+ }
110
+
111
+ function resolveQueryMeta(query = null) {
112
+ if (isRecord(query?.meta)) {
113
+ return query.meta;
114
+ }
115
+ if (isRecord(query?.options?.meta)) {
116
+ return query.options.meta;
117
+ }
118
+ return {};
119
+ }
120
+
121
+ function isRequestRecoveryDisabled(query = null) {
122
+ const meta = resolveQueryMeta(query);
123
+ if (meta.jskitRequestRecovery === false || meta.requestRecovery === false) {
124
+ return true;
125
+ }
126
+
127
+ const jskitMeta = isRecord(meta.jskit) ? meta.jskit : {};
128
+ return jskitMeta.requestRecovery === false;
129
+ }
130
+
131
+ function resolveQueryRecoveryLabel(query = null) {
132
+ const meta = resolveQueryMeta(query);
133
+ const jskitMeta = isRecord(meta.jskit) ? meta.jskit : {};
134
+
135
+ return normalizeText(
136
+ jskitMeta.requestRecoveryLabel ||
137
+ jskitMeta.label ||
138
+ meta.jskitRequestRecoveryLabel ||
139
+ meta.requestRecoveryLabel ||
140
+ meta.label,
141
+ "Request"
142
+ );
143
+ }
144
+
145
+ function isActiveQuery(query = null) {
146
+ if (typeof query?.isActive === "function") {
147
+ return Boolean(query.isActive());
148
+ }
149
+ if (typeof query?.getObserversCount === "function") {
150
+ return Number(query.getObserversCount()) > 0;
151
+ }
152
+ return true;
153
+ }
154
+
155
+ function recoverableQueryError(query = null) {
156
+ const state = isRecord(query?.state) ? query.state : {};
157
+ if (state.status !== "error" || state.fetchStatus !== "idle") {
158
+ return null;
159
+ }
160
+
161
+ const error = state.error || state.fetchFailureReason || null;
162
+ return isRecoverableRequestError(error) ? error : null;
163
+ }
164
+
165
+ function createQueryRetry(queryClient, query = null) {
166
+ if (!queryClient || typeof queryClient.refetchQueries !== "function") {
167
+ return null;
168
+ }
169
+
170
+ const queryKey = query?.queryKey;
171
+ if (!Array.isArray(queryKey)) {
172
+ return null;
173
+ }
174
+
175
+ return () =>
176
+ queryClient.refetchQueries(
177
+ {
178
+ queryKey,
179
+ exact: true,
180
+ type: "active"
181
+ },
182
+ {
183
+ throwOnError: false
184
+ }
185
+ );
186
+ }
187
+
188
+ function resolveQueryHash(query = null) {
189
+ const explicitHash = normalizeText(query?.queryHash);
190
+ if (explicitHash) {
191
+ return explicitHash;
192
+ }
193
+
194
+ try {
195
+ return normalizeText(JSON.stringify(query?.queryKey || []));
196
+ } catch {
197
+ return normalizeText(String(query?.queryKey || ""));
198
+ }
199
+ }
200
+
201
+ function installRecoverableQueryObserver({
202
+ app,
203
+ runtime,
204
+ logger
205
+ } = {}) {
206
+ if (!app?.has?.("jskit.client.query-client")) {
207
+ return Object.freeze({
208
+ dispose() {}
209
+ });
210
+ }
211
+
212
+ const queryClient = app.make("jskit.client.query-client");
213
+ const queryCache =
214
+ queryClient && typeof queryClient.getQueryCache === "function"
215
+ ? queryClient.getQueryCache()
216
+ : null;
217
+ if (!queryCache || typeof queryCache.subscribe !== "function") {
218
+ return Object.freeze({
219
+ dispose() {}
220
+ });
221
+ }
222
+
223
+ const reportedErrorUpdateByQueryHash = new Map();
224
+
225
+ function inspectQuery(query = null) {
226
+ if (!query || isRequestRecoveryDisabled(query) || !isActiveQuery(query)) {
227
+ return;
228
+ }
229
+
230
+ const error = recoverableQueryError(query);
231
+ const queryHash = resolveQueryHash(query);
232
+ if (!error || !queryHash) {
233
+ reportedErrorUpdateByQueryHash.delete(queryHash);
234
+ return;
235
+ }
236
+
237
+ const errorUpdateCount = Number(query?.state?.errorUpdateCount || 0);
238
+ const reportKey = `${errorUpdateCount}:${errorMessage(error)}`;
239
+ if (reportedErrorUpdateByQueryHash.get(queryHash) === reportKey) {
240
+ return;
241
+ }
242
+ reportedErrorUpdateByQueryHash.set(queryHash, reportKey);
243
+
244
+ runtime.report(error, {
245
+ label: resolveQueryRecoveryLabel(query),
246
+ retry: createQueryRetry(queryClient, query),
247
+ source: "shell-web.request-recovery.query",
248
+ stale: query?.state?.data !== undefined,
249
+ dedupeKey: `shell-web.request-recovery.query.${queryHash}.${errorUpdateCount}`
250
+ });
251
+ }
252
+
253
+ try {
254
+ if (typeof queryCache.getAll === "function") {
255
+ for (const query of queryCache.getAll()) {
256
+ inspectQuery(query);
257
+ }
258
+ }
259
+ } catch (error) {
260
+ logger.warn(
261
+ {
262
+ error: errorMessage(error) || "unknown error"
263
+ },
264
+ "Shell request recovery could not inspect existing queries."
265
+ );
266
+ }
267
+
268
+ const unsubscribe = queryCache.subscribe((event = {}) => {
269
+ try {
270
+ inspectQuery(event?.query);
271
+ } catch (error) {
272
+ logger.warn(
273
+ {
274
+ error: errorMessage(error) || "unknown error"
275
+ },
276
+ "Shell request recovery query observer failed."
277
+ );
278
+ }
279
+ });
280
+
281
+ return Object.freeze({
282
+ dispose() {
283
+ reportedErrorUpdateByQueryHash.clear();
284
+ if (typeof unsubscribe === "function") {
285
+ unsubscribe();
286
+ }
287
+ }
288
+ });
289
+ }
290
+
291
+ function createShellRequestRecoveryRuntime({
292
+ app,
293
+ logger = null
294
+ } = {}) {
295
+ if (!app || typeof app.has !== "function" || typeof app.make !== "function") {
296
+ throw new Error("createShellRequestRecoveryRuntime requires application has()/make().");
297
+ }
298
+
299
+ const runtimeLogger = logger || createSharedProviderLogger(app);
300
+ let installedQueryObserver = null;
301
+ let reloadAttempt = 0;
302
+
303
+ function errorRuntime() {
304
+ if (!app.has("runtime.web-error.client")) {
305
+ return null;
306
+ }
307
+ const runtime = app.make("runtime.web-error.client");
308
+ return runtime && typeof runtime.report === "function" ? runtime : null;
309
+ }
310
+
311
+ async function reload({
312
+ label = "App",
313
+ message = REQUEST_RECOVERY_RELOAD_FAILURE_MESSAGE
314
+ } = {}) {
315
+ const reloaded = await guardedReloadApp({
316
+ label,
317
+ message
318
+ });
319
+ if (!reloaded) {
320
+ reloadAttempt += 1;
321
+ report(new Error("Network request failed."), {
322
+ label,
323
+ message,
324
+ reload: true,
325
+ force: true,
326
+ source: "shell-web.request-recovery.reload",
327
+ dedupeKey: `shell-web.request-recovery.reload.${reloadAttempt}`
328
+ });
329
+ }
330
+ return reloaded;
331
+ }
332
+
333
+ function retryAction(error, options, retry) {
334
+ return {
335
+ label: normalizeText(options.actionLabel, "Retry"),
336
+ dismissOnRun: true,
337
+ async handler() {
338
+ try {
339
+ return await retry();
340
+ } catch (retryError) {
341
+ if (isRecoverableRequestError(retryError)) {
342
+ return report(retryError, options);
343
+ }
344
+ throw retryError;
345
+ }
346
+ }
347
+ };
348
+ }
349
+
350
+ function reloadAction(options) {
351
+ return {
352
+ label: normalizeText(options.actionLabel, "Reload"),
353
+ dismissOnRun: true,
354
+ handler() {
355
+ return reload({
356
+ label: options.label,
357
+ message: options.reloadFailureMessage || REQUEST_RECOVERY_RELOAD_FAILURE_MESSAGE
358
+ });
359
+ }
360
+ };
361
+ }
362
+
363
+ function report(error = null, options = {}) {
364
+ if (!isRecoverableRequestError(error) && options.force !== true) {
365
+ return null;
366
+ }
367
+
368
+ const runtime = errorRuntime();
369
+ const source = normalizeText(options.source, "shell-web.request-recovery");
370
+ if (!runtime) {
371
+ runtimeLogger.warn(
372
+ {
373
+ source,
374
+ error: errorMessage(error) || "unknown error"
375
+ },
376
+ "Shell request recovery could not report because the shell error runtime is unavailable."
377
+ );
378
+ return null;
379
+ }
380
+
381
+ const retry = typeof options.retry === "function" ? options.retry : null;
382
+ const action = retry
383
+ ? retryAction(error, options, retry)
384
+ : options.reload === true
385
+ ? reloadAction(options)
386
+ : null;
387
+
388
+ try {
389
+ return runtime.report({
390
+ source,
391
+ message: requestRecoveryMessage(error, options),
392
+ cause: error || null,
393
+ intent: "app-recoverable",
394
+ severity: normalizeText(options.severity, options.stale ? "warning" : "error"),
395
+ dedupeKey: normalizeText(options.dedupeKey),
396
+ dedupeWindowMs: Number.isFinite(Number(options.dedupeWindowMs))
397
+ ? Math.max(0, Number(options.dedupeWindowMs))
398
+ : 2000,
399
+ action
400
+ });
401
+ } catch (reportError) {
402
+ runtimeLogger.error(
403
+ {
404
+ source,
405
+ error: errorMessage(reportError) || "unknown error"
406
+ },
407
+ "Shell request recovery could not report through the shell error runtime."
408
+ );
409
+ return null;
410
+ }
411
+ }
412
+
413
+ function install() {
414
+ if (installedQueryObserver) {
415
+ return installedQueryObserver;
416
+ }
417
+
418
+ installedQueryObserver = installRecoverableQueryObserver({
419
+ app,
420
+ runtime: api,
421
+ logger: runtimeLogger
422
+ });
423
+ return installedQueryObserver;
424
+ }
425
+
426
+ function dispose() {
427
+ installedQueryObserver?.dispose?.();
428
+ installedQueryObserver = null;
429
+ }
430
+
431
+ const api = Object.freeze({
432
+ dispose,
433
+ install,
434
+ isRecoverableRequestError,
435
+ reload,
436
+ report
437
+ });
438
+
439
+ return api;
440
+ }
441
+
442
+ export {
443
+ createShellRequestRecoveryRuntime,
444
+ isRecoverableRequestError,
445
+ requestRecoveryMessage
446
+ };
@@ -50,6 +50,14 @@ function createShellBootstrapRuntime({
50
50
  let initialized = false;
51
51
  let refreshQueue = Promise.resolve();
52
52
 
53
+ function requestRecoveryRuntime() {
54
+ if (!app.has("runtime.web-request-recovery.client")) {
55
+ return null;
56
+ }
57
+ const runtime = app.make("runtime.web-request-recovery.client");
58
+ return runtime && typeof runtime.report === "function" ? runtime : null;
59
+ }
60
+
53
61
  async function resolveBootstrapRequest(reason = "manual") {
54
62
  const handlers = resolveBootstrapPayloadHandlers(app);
55
63
  let request = {
@@ -162,6 +170,12 @@ function createShellBootstrapRuntime({
162
170
  return applyBootstrapPayload(payload, reason, request);
163
171
  } catch (error) {
164
172
  await applyBootstrapError(error, reason, request);
173
+ requestRecoveryRuntime()?.report?.(error, {
174
+ label: "App data",
175
+ retry: () => refresh(reason),
176
+ source: "shell-web.bootstrap",
177
+ dedupeKey: `shell-web.bootstrap.${String(reason || "manual").trim() || "manual"}.request-failed`
178
+ });
165
179
  runtimeLogger.warn(
166
180
  {
167
181
  reason,
@@ -0,0 +1,49 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createApp } from "vue";
4
+ import {
5
+ SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY,
6
+ useShellAsyncModuleRecoveryRuntime
7
+ } from "../src/client/asyncModuleRecovery/index.js";
8
+
9
+ test("shell async module recovery runtime composable returns null outside Vue injection context", () => {
10
+ assert.equal(useShellAsyncModuleRecoveryRuntime(), null);
11
+ });
12
+
13
+ test("shell async module recovery runtime composable resolves the provided public runtime", async () => {
14
+ const runtime = {
15
+ notify(error, options) {
16
+ return { error, options };
17
+ },
18
+ async reload() {
19
+ return true;
20
+ }
21
+ };
22
+ const app = createApp({
23
+ render() {
24
+ return null;
25
+ }
26
+ });
27
+ app.provide(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY, runtime);
28
+
29
+ assert.equal(
30
+ app.runWithContext(() => useShellAsyncModuleRecoveryRuntime()),
31
+ runtime
32
+ );
33
+ });
34
+
35
+ test("shell async module recovery runtime composable rejects incomplete runtimes", () => {
36
+ const app = createApp({
37
+ render() {
38
+ return null;
39
+ }
40
+ });
41
+ app.provide(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY, {
42
+ notify() {}
43
+ });
44
+
45
+ assert.equal(
46
+ app.runWithContext(() => useShellAsyncModuleRecoveryRuntime()),
47
+ null
48
+ );
49
+ });
@@ -8,6 +8,12 @@ import {
8
8
  import {
9
9
  isMissingDynamicModule
10
10
  } from "../src/client/providers/appModuleLoadFailure.js";
11
+ import {
12
+ SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY
13
+ } from "../src/client/asyncModuleRecovery/index.js";
14
+ import {
15
+ SHELL_REQUEST_RECOVERY_RUNTIME_KEY
16
+ } from "../src/client/requestRecovery/index.js";
11
17
  import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
12
18
  const CLIENT_APP_CONFIG_GLOBAL_KEY = "__JSKIT_CLIENT_APP_CONFIG__";
13
19
 
@@ -166,6 +172,32 @@ function createWindowDouble({ href = "https://example.test/home" } = {}) {
166
172
  };
167
173
  }
168
174
 
175
+ function createQueryCacheDouble(initialQueries = []) {
176
+ const listeners = [];
177
+ const queries = [...initialQueries];
178
+
179
+ return {
180
+ queries,
181
+ getAll() {
182
+ return queries;
183
+ },
184
+ subscribe(listener) {
185
+ listeners.push(listener);
186
+ return () => {
187
+ const index = listeners.indexOf(listener);
188
+ if (index >= 0) {
189
+ listeners.splice(index, 1);
190
+ }
191
+ };
192
+ },
193
+ emit(event) {
194
+ for (const listener of [...listeners]) {
195
+ listener(event);
196
+ }
197
+ }
198
+ };
199
+ }
200
+
169
201
  test("shell web client provider preserves append-only placement topology object exports", () => {
170
202
  const warnings = [];
171
203
  const topology = {
@@ -220,6 +252,7 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
220
252
  assert.equal(app.singletons.has("runtime.web-error.presentation-store.client"), true);
221
253
  assert.equal(app.singletons.has("runtime.web-refresh.client"), true);
222
254
  assert.equal(app.singletons.has("runtime.web-async-module-recovery.client"), true);
255
+ assert.equal(app.singletons.has("runtime.web-request-recovery.client"), true);
223
256
 
224
257
  await provider.boot(app);
225
258
  assert.equal(app.plugins.length, 0);
@@ -229,7 +262,8 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
229
262
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-placement.client"), true);
230
263
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-refresh.client"), true);
231
264
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.client"), true);
232
- assert.equal(providedByKey.has("jskit.shell-web.runtime.web-async-module-recovery.client"), true);
265
+ assert.equal(providedByKey.has(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY), true);
266
+ assert.equal(providedByKey.has(SHELL_REQUEST_RECOVERY_RUNTIME_KEY), true);
233
267
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.presentation-store.client"), true);
234
268
 
235
269
  const placementRuntime = providedByKey.get("jskit.shell-web.runtime.web-placement.client");
@@ -245,11 +279,16 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
245
279
  const refreshRuntime = providedByKey.get("jskit.shell-web.runtime.web-refresh.client");
246
280
  assert.equal(typeof refreshRuntime.refresh, "function");
247
281
 
248
- const asyncModuleRecoveryRuntime = providedByKey.get("jskit.shell-web.runtime.web-async-module-recovery.client");
282
+ const asyncModuleRecoveryRuntime = providedByKey.get(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY);
249
283
  assert.equal(typeof asyncModuleRecoveryRuntime.install, "function");
250
284
  assert.equal(typeof asyncModuleRecoveryRuntime.notify, "function");
251
285
  assert.equal(typeof asyncModuleRecoveryRuntime.reload, "function");
252
286
 
287
+ const requestRecoveryRuntime = providedByKey.get(SHELL_REQUEST_RECOVERY_RUNTIME_KEY);
288
+ assert.equal(typeof requestRecoveryRuntime.install, "function");
289
+ assert.equal(typeof requestRecoveryRuntime.report, "function");
290
+ assert.equal(typeof requestRecoveryRuntime.reload, "function");
291
+
253
292
  const errorStore = providedByKey.get("jskit.shell-web.runtime.web-error.presentation-store.client");
254
293
  assert.equal(typeof errorStore.getState, "function");
255
294
  assert.equal(typeof errorStore.present, "function");
@@ -316,6 +355,132 @@ test("shell refresh runtime reports recoverable retry errors as banners", async
316
355
  });
317
356
  });
318
357
 
358
+ test("shell request recovery reports active query transport failures with retry actions", async () => {
359
+ await withFetchStub({ surfaceAccess: {} }, async () => {
360
+ const refetchCalls = [];
361
+ const queryCache = createQueryCacheDouble();
362
+ const queryClient = {
363
+ getQueryCache() {
364
+ return queryCache;
365
+ },
366
+ async refetchQueries(filters = {}, options = {}) {
367
+ refetchCalls.push({ filters, options });
368
+ }
369
+ };
370
+ const app = createAppDouble({ queryClient });
371
+ const provider = new ShellWebClientProvider();
372
+ provider.register(app);
373
+ await provider.boot(app);
374
+
375
+ const failedQuery = {
376
+ queryHash: "[\"project-access\"]",
377
+ queryKey: ["project-access"],
378
+ meta: {
379
+ jskit: {
380
+ requestRecoveryLabel: "Project access"
381
+ }
382
+ },
383
+ state: {
384
+ status: "error",
385
+ fetchStatus: "idle",
386
+ error: {
387
+ status: 0,
388
+ message: "Network request failed."
389
+ },
390
+ errorUpdateCount: 1
391
+ },
392
+ isActive() {
393
+ return true;
394
+ }
395
+ };
396
+ queryCache.emit({ type: "updated", query: failedQuery });
397
+
398
+ const errorStore = app.make("runtime.web-error.presentation-store.client");
399
+ const state = errorStore.getState();
400
+ assert.equal(state.channels.banner.length, 1);
401
+ assert.equal(
402
+ state.channels.banner[0].message,
403
+ "Project access could not reach the server or network. Check the connection and try again."
404
+ );
405
+ assert.equal(state.channels.banner[0].action.label, "Retry");
406
+
407
+ await state.channels.banner[0].action.handler();
408
+ assert.equal(refetchCalls.length, 1);
409
+ assert.deepEqual(refetchCalls[0].filters, {
410
+ queryKey: ["project-access"],
411
+ exact: true,
412
+ type: "active"
413
+ });
414
+ assert.deepEqual(refetchCalls[0].options, {
415
+ throwOnError: false
416
+ });
417
+ });
418
+ });
419
+
420
+ test("shell request recovery ignores ordinary active query validation failures", async () => {
421
+ await withFetchStub({ surfaceAccess: {} }, async () => {
422
+ const queryCache = createQueryCacheDouble();
423
+ const queryClient = {
424
+ getQueryCache() {
425
+ return queryCache;
426
+ },
427
+ async refetchQueries() {}
428
+ };
429
+ const app = createAppDouble({ queryClient });
430
+ const provider = new ShellWebClientProvider();
431
+ provider.register(app);
432
+ await provider.boot(app);
433
+
434
+ queryCache.emit({
435
+ type: "updated",
436
+ query: {
437
+ queryHash: "[\"project-access\"]",
438
+ queryKey: ["project-access"],
439
+ state: {
440
+ status: "error",
441
+ fetchStatus: "idle",
442
+ error: {
443
+ status: 422,
444
+ message: "Invalid input."
445
+ },
446
+ errorUpdateCount: 1
447
+ },
448
+ isActive() {
449
+ return true;
450
+ }
451
+ }
452
+ });
453
+
454
+ const errorStore = app.make("runtime.web-error.presentation-store.client");
455
+ assert.equal(errorStore.getState().channels.banner.length, 0);
456
+ });
457
+ });
458
+
459
+ test("shell bootstrap transport failures report through request recovery", async () => {
460
+ let fetchCalls = 0;
461
+ await withFetchImplementation(async () => {
462
+ fetchCalls += 1;
463
+ throw new TypeError("Failed to fetch");
464
+ }, async () => {
465
+ const app = createAppDouble();
466
+ const provider = new ShellWebClientProvider();
467
+ provider.register(app);
468
+ await provider.boot(app);
469
+
470
+ const errorStore = app.make("runtime.web-error.presentation-store.client");
471
+ const state = errorStore.getState();
472
+ assert.equal(state.channels.banner.length, 1);
473
+ assert.equal(
474
+ state.channels.banner[0].message,
475
+ "App data could not reach the server or network. Check the connection and try again."
476
+ );
477
+ assert.equal(state.channels.banner[0].action.label, "Retry");
478
+
479
+ await state.channels.banner[0].action.handler();
480
+ assert.equal(fetchCalls, 2);
481
+ });
482
+ });
483
+
319
484
  test("shell web client provider reports dynamic import failures through async module recovery", async () => {
320
485
  await withFetchStub({ surfaceAccess: {} }, async () => {
321
486
  const routerErrorHandlers = [];
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createApp } from "vue";
4
+ import {
5
+ SHELL_REQUEST_RECOVERY_RUNTIME_KEY,
6
+ isRecoverableRequestError,
7
+ requestRecoveryMessage,
8
+ useShellRequestRecoveryRuntime
9
+ } from "../src/client/requestRecovery/index.js";
10
+
11
+ test("shell request recovery classifier accepts transport failures only", () => {
12
+ assert.equal(isRecoverableRequestError({ status: 0, message: "Network request failed." }), true);
13
+ assert.equal(isRecoverableRequestError(new TypeError("Failed to fetch")), true);
14
+ assert.equal(isRecoverableRequestError({ code: "ECONNREFUSED", message: "fetch failed" }), true);
15
+ assert.equal(isRecoverableRequestError({ status: 422, message: "Invalid input." }), false);
16
+ assert.equal(isRecoverableRequestError(new Error("Business rule failed.")), false);
17
+ assert.equal(isRecoverableRequestError({ name: "AbortError", message: "The operation was aborted." }), false);
18
+ assert.equal(isRecoverableRequestError({ code: "ERR_CANCELED", message: "canceled" }), false);
19
+ });
20
+
21
+ test("shell request recovery message uses app-facing labels", () => {
22
+ assert.equal(
23
+ requestRecoveryMessage(null, { label: "Project access" }),
24
+ "Project access could not reach the server or network. Check the connection and try again."
25
+ );
26
+ assert.equal(
27
+ requestRecoveryMessage(null, { message: "Custom recovery copy." }),
28
+ "Custom recovery copy."
29
+ );
30
+ });
31
+
32
+ test("shell request recovery runtime composable returns null outside Vue injection context", () => {
33
+ assert.equal(useShellRequestRecoveryRuntime(), null);
34
+ });
35
+
36
+ test("shell request recovery runtime composable resolves the provided public runtime", () => {
37
+ const runtime = {
38
+ report(error, options) {
39
+ return { error, options };
40
+ },
41
+ async reload() {
42
+ return true;
43
+ }
44
+ };
45
+ const app = createApp({
46
+ render() {
47
+ return null;
48
+ }
49
+ });
50
+ app.provide(SHELL_REQUEST_RECOVERY_RUNTIME_KEY, runtime);
51
+
52
+ assert.equal(
53
+ app.runWithContext(() => useShellRequestRecoveryRuntime()),
54
+ runtime
55
+ );
56
+ });
57
+
58
+ test("shell request recovery runtime composable rejects incomplete runtimes", () => {
59
+ const app = createApp({
60
+ render() {
61
+ return null;
62
+ }
63
+ });
64
+ app.provide(SHELL_REQUEST_RECOVERY_RUNTIME_KEY, {
65
+ report() {}
66
+ });
67
+
68
+ assert.equal(
69
+ app.runWithContext(() => useShellRequestRecoveryRuntime()),
70
+ null
71
+ );
72
+ });
@@ -148,6 +148,52 @@ test("shell-web installs generated adaptive shell Playwright smoke coverage", as
148
148
  assert.equal(packageJson?.exports?.["./test/adaptiveShellSmoke"], "./src/test/adaptiveShellSmoke.js");
149
149
  });
150
150
 
151
+ test("shell-web exports async module recovery runtime access as a public client API", async () => {
152
+ const packageJson = JSON.parse(await readFile(path.join(PACKAGE_DIR, "package.json"), "utf8"));
153
+ const clientIndex = await readFile(path.join(PACKAGE_DIR, "src", "client", "index.js"), "utf8");
154
+ const recoveryIndex = await readFile(
155
+ path.join(PACKAGE_DIR, "src", "client", "asyncModuleRecovery", "index.js"),
156
+ "utf8"
157
+ );
158
+ const providerSource = await readFile(
159
+ path.join(PACKAGE_DIR, "src", "client", "providers", "ShellWebClientProvider.js"),
160
+ "utf8"
161
+ );
162
+
163
+ assert.equal(
164
+ packageJson?.exports?.["./client/asyncModuleRecovery"],
165
+ "./src/client/asyncModuleRecovery/index.js"
166
+ );
167
+ assert.match(clientIndex, /useShellAsyncModuleRecoveryRuntime/);
168
+ assert.match(clientIndex, /SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY/);
169
+ assert.match(recoveryIndex, /useShellAsyncModuleRecoveryRuntime/);
170
+ assert.match(recoveryIndex, /SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY/);
171
+ assert.match(providerSource, /SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY/);
172
+ });
173
+
174
+ test("shell-web exports request recovery runtime access as a public client API", async () => {
175
+ const packageJson = JSON.parse(await readFile(path.join(PACKAGE_DIR, "package.json"), "utf8"));
176
+ const clientIndex = await readFile(path.join(PACKAGE_DIR, "src", "client", "index.js"), "utf8");
177
+ const recoveryIndex = await readFile(
178
+ path.join(PACKAGE_DIR, "src", "client", "requestRecovery", "index.js"),
179
+ "utf8"
180
+ );
181
+ const providerSource = await readFile(
182
+ path.join(PACKAGE_DIR, "src", "client", "providers", "ShellWebClientProvider.js"),
183
+ "utf8"
184
+ );
185
+
186
+ assert.equal(
187
+ packageJson?.exports?.["./client/requestRecovery"],
188
+ "./src/client/requestRecovery/index.js"
189
+ );
190
+ assert.match(clientIndex, /useShellRequestRecoveryRuntime/);
191
+ assert.match(clientIndex, /SHELL_REQUEST_RECOVERY_RUNTIME_KEY/);
192
+ assert.match(recoveryIndex, /useShellRequestRecoveryRuntime/);
193
+ assert.match(recoveryIndex, /SHELL_REQUEST_RECOVERY_RUNTIME_KEY/);
194
+ assert.match(providerSource, /SHELL_REQUEST_RECOVERY_RUNTIME_KEY/);
195
+ });
196
+
151
197
  test("shell-web route transition keeps mobile route motion placement-driven", async () => {
152
198
  const source = await readFile(
153
199
  path.join(PACKAGE_DIR, "src", "client", "components", "ShellRouteTransition.vue"),
@@ -275,6 +321,7 @@ test("shell-web descriptor metadata advertises adaptive shell outlets, default l
275
321
  "runtime.web-bootstrap.client",
276
322
  "runtime.web-refresh.client",
277
323
  "runtime.web-async-module-recovery.client",
324
+ "runtime.web-request-recovery.client",
278
325
  "runtime.web-error.client",
279
326
  "runtime.web-error.presentation-store.client"
280
327
  ]);