@jskit-ai/shell-web 0.1.87 → 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.87",
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.88"
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.87",
3
+ "version": "0.1.88",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -10,6 +10,7 @@
10
10
  "./client/error": "./src/client/error/index.js",
11
11
  "./client/placement": "./src/client/placement/index.js",
12
12
  "./client/asyncModuleRecovery": "./src/client/asyncModuleRecovery/index.js",
13
+ "./client/requestRecovery": "./src/client/requestRecovery/index.js",
13
14
  "./client/bootstrap": "./src/client/bootstrap/index.js",
14
15
  "./server/support/localLinkItemScaffolds": "./src/server/support/localLinkItemScaffolds.js",
15
16
  "./client/navigation/linkResolver": "./src/client/navigation/linkResolver.js",
@@ -27,7 +28,7 @@
27
28
  },
28
29
  "dependencies": {
29
30
  "@mdi/js": "^7.4.47",
30
- "@jskit-ai/kernel": "0.1.88"
31
+ "@jskit-ai/kernel": "0.1.89"
31
32
  },
32
33
  "peerDependencies": {
33
34
  "pinia": "^3.0.4",
@@ -21,6 +21,12 @@ export {
21
21
  SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY,
22
22
  useShellAsyncModuleRecoveryRuntime
23
23
  } from "./asyncModuleRecovery/index.js";
24
+ export {
25
+ SHELL_REQUEST_RECOVERY_RUNTIME_KEY,
26
+ isRecoverableRequestError,
27
+ requestRecoveryMessage,
28
+ useShellRequestRecoveryRuntime
29
+ } from "./requestRecovery/index.js";
24
30
  export {
25
31
  BOOTSTRAP_PAYLOAD_HANDLER_TAG,
26
32
  registerBootstrapPayloadHandler,
@@ -13,6 +13,12 @@ import {
13
13
  import {
14
14
  SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY
15
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";
16
22
  import {
17
23
  isRecord
18
24
  } from "@jskit-ai/kernel/shared/support";
@@ -577,6 +583,12 @@ class ShellWebClientProvider {
577
583
  logger
578
584
  })
579
585
  );
586
+ app.singleton("runtime.web-request-recovery.client", (scope) =>
587
+ createShellRequestRecoveryRuntime({
588
+ app: scope,
589
+ logger
590
+ })
591
+ );
580
592
  app.singleton("runtime.web-error.presentation-store.client", () => createErrorPresentationStore());
581
593
  app.singleton("runtime.web-error.client", (scope) =>
582
594
  createErrorRuntime({
@@ -598,6 +610,7 @@ class ShellWebClientProvider {
598
610
  const logger = createSharedProviderLogger(isRecord(app) ? app : null);
599
611
  const errorRuntime = app.make("runtime.web-error.client");
600
612
  const asyncModuleRecoveryRuntime = app.make("runtime.web-async-module-recovery.client");
613
+ const requestRecoveryRuntime = app.make("runtime.web-request-recovery.client");
601
614
  asyncModuleRecoveryRuntime.install();
602
615
 
603
616
  const placementRuntime = app.make("runtime.web-placement.client");
@@ -632,6 +645,7 @@ class ShellWebClientProvider {
632
645
 
633
646
  const errorConfig = await loadAppErrorConfig(logger, errorRuntime, asyncModuleRecoveryRuntime);
634
647
  applyAppErrorConfig(errorRuntime, errorConfig);
648
+ requestRecoveryRuntime.install();
635
649
 
636
650
  const bootstrapRuntime = app.make("runtime.web-bootstrap.client");
637
651
  if (bootstrapRuntime && typeof bootstrapRuntime.initialize === "function") {
@@ -658,6 +672,7 @@ class ShellWebClientProvider {
658
672
  vueApp.provide("jskit.shell-web.runtime.web-refresh.client", refreshRuntime);
659
673
  vueApp.provide("jskit.shell-web.runtime.web-error.client", errorRuntime);
660
674
  vueApp.provide(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY, asyncModuleRecoveryRuntime);
675
+ vueApp.provide(SHELL_REQUEST_RECOVERY_RUNTIME_KEY, requestRecoveryRuntime);
661
676
  vueApp.provide(
662
677
  "jskit.shell-web.runtime.web-error.presentation-store.client",
663
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,
@@ -11,6 +11,9 @@ import {
11
11
  import {
12
12
  SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY
13
13
  } from "../src/client/asyncModuleRecovery/index.js";
14
+ import {
15
+ SHELL_REQUEST_RECOVERY_RUNTIME_KEY
16
+ } from "../src/client/requestRecovery/index.js";
14
17
  import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
15
18
  const CLIENT_APP_CONFIG_GLOBAL_KEY = "__JSKIT_CLIENT_APP_CONFIG__";
16
19
 
@@ -169,6 +172,32 @@ function createWindowDouble({ href = "https://example.test/home" } = {}) {
169
172
  };
170
173
  }
171
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
+
172
201
  test("shell web client provider preserves append-only placement topology object exports", () => {
173
202
  const warnings = [];
174
203
  const topology = {
@@ -223,6 +252,7 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
223
252
  assert.equal(app.singletons.has("runtime.web-error.presentation-store.client"), true);
224
253
  assert.equal(app.singletons.has("runtime.web-refresh.client"), true);
225
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);
226
256
 
227
257
  await provider.boot(app);
228
258
  assert.equal(app.plugins.length, 0);
@@ -233,6 +263,7 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
233
263
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-refresh.client"), true);
234
264
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.client"), true);
235
265
  assert.equal(providedByKey.has(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY), true);
266
+ assert.equal(providedByKey.has(SHELL_REQUEST_RECOVERY_RUNTIME_KEY), true);
236
267
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.presentation-store.client"), true);
237
268
 
238
269
  const placementRuntime = providedByKey.get("jskit.shell-web.runtime.web-placement.client");
@@ -253,6 +284,11 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
253
284
  assert.equal(typeof asyncModuleRecoveryRuntime.notify, "function");
254
285
  assert.equal(typeof asyncModuleRecoveryRuntime.reload, "function");
255
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
+
256
292
  const errorStore = providedByKey.get("jskit.shell-web.runtime.web-error.presentation-store.client");
257
293
  assert.equal(typeof errorStore.getState, "function");
258
294
  assert.equal(typeof errorStore.present, "function");
@@ -319,6 +355,132 @@ test("shell refresh runtime reports recoverable retry errors as banners", async
319
355
  });
320
356
  });
321
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
+
322
484
  test("shell web client provider reports dynamic import failures through async module recovery", async () => {
323
485
  await withFetchStub({ surfaceAccess: {} }, async () => {
324
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
+ });
@@ -171,6 +171,29 @@ test("shell-web exports async module recovery runtime access as a public client
171
171
  assert.match(providerSource, /SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY/);
172
172
  });
173
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
+
174
197
  test("shell-web route transition keeps mobile route motion placement-driven", async () => {
175
198
  const source = await readFile(
176
199
  path.join(PACKAGE_DIR, "src", "client", "components", "ShellRouteTransition.vue"),
@@ -298,6 +321,7 @@ test("shell-web descriptor metadata advertises adaptive shell outlets, default l
298
321
  "runtime.web-bootstrap.client",
299
322
  "runtime.web-refresh.client",
300
323
  "runtime.web-async-module-recovery.client",
324
+ "runtime.web-request-recovery.client",
301
325
  "runtime.web-error.client",
302
326
  "runtime.web-error.presentation-store.client"
303
327
  ]);