@jskit-ai/shell-web 0.1.85 → 0.1.87

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,14 +1,15 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/shell-web",
4
- version: "0.1.85",
4
+ version: "0.1.87",
5
5
  kind: "runtime",
6
6
  description: "Web shell layout runtime with outlet-based placement contributions.",
7
7
  dependsOn: [],
8
8
  capabilities: {
9
9
  provides: [
10
10
  "runtime.web-placement",
11
- "runtime.web-error"
11
+ "runtime.web-error",
12
+ "runtime.web-async-module-recovery"
12
13
  ],
13
14
  requires: []
14
15
  },
@@ -54,6 +55,8 @@ export default Object.freeze({
54
55
  client: [
55
56
  "runtime.web-placement.client",
56
57
  "runtime.web-bootstrap.client",
58
+ "runtime.web-refresh.client",
59
+ "runtime.web-async-module-recovery.client",
57
60
  "runtime.web-error.client",
58
61
  "runtime.web-error.presentation-store.client"
59
62
  ]
@@ -291,7 +294,7 @@ export default Object.freeze({
291
294
  dependencies: {
292
295
  runtime: {
293
296
  "@mdi/js": "^7.4.47",
294
- "@jskit-ai/kernel": "0.1.86"
297
+ "@jskit-ai/kernel": "0.1.88"
295
298
  },
296
299
  dev: {}
297
300
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/shell-web",
3
- "version": "0.1.85",
3
+ "version": "0.1.87",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -9,6 +9,7 @@
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",
12
13
  "./client/bootstrap": "./src/client/bootstrap/index.js",
13
14
  "./server/support/localLinkItemScaffolds": "./src/server/support/localLinkItemScaffolds.js",
14
15
  "./client/navigation/linkResolver": "./src/client/navigation/linkResolver.js",
@@ -26,7 +27,7 @@
26
27
  },
27
28
  "dependencies": {
28
29
  "@mdi/js": "^7.4.47",
29
- "@jskit-ai/kernel": "0.1.86"
30
+ "@jskit-ai/kernel": "0.1.88"
30
31
  },
31
32
  "peerDependencies": {
32
33
  "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,10 @@ 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";
20
24
  export {
21
25
  BOOTSTRAP_PAYLOAD_HANDLER_TAG,
22
26
  registerBootstrapPayloadHandler,
@@ -1,4 +1,18 @@
1
1
  import { getClientAppConfig } from "@jskit-ai/kernel/client";
2
+ import {
3
+ createAsyncModuleRecoveryState,
4
+ guardedReloadApp,
5
+ installAsyncModuleRecoveryHandlers,
6
+ isDynamicImportError,
7
+ notifyAsyncModuleLoadError
8
+ } from "@jskit-ai/kernel/client/asyncModuleRecovery";
9
+ import {
10
+ isMissingDynamicModule,
11
+ notifyDynamicImportFailure
12
+ } from "./appModuleLoadFailure.js";
13
+ import {
14
+ SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY
15
+ } from "../asyncModuleRecovery/inject.js";
2
16
  import {
3
17
  isRecord
4
18
  } from "@jskit-ai/kernel/shared/support";
@@ -29,18 +43,10 @@ import { resolveBootstrapErrorStatusCode } from "../bootstrap/bootstrapErrorStat
29
43
  const APP_PLACEMENT_MODULE_SPECIFIER = "/src/placement.js";
30
44
  const APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER = "/src/placementTopology.js";
31
45
  const APP_ERROR_MODULE_SPECIFIER = "/src/error.js";
46
+ const ASYNC_MODULE_RELOAD_FAILURE_MESSAGE =
47
+ "The app cannot reload because the app server is not reachable. Restart the server, then click Reload.";
32
48
 
33
- function isMissingDynamicModule(error, moduleSpecifier) {
34
- const message = String(error?.message || error || "");
35
- return (
36
- message.includes(moduleSpecifier) &&
37
- (message.includes("Cannot find module") ||
38
- message.includes("Failed to fetch dynamically imported module") ||
39
- message.includes("ERR_MODULE_NOT_FOUND"))
40
- );
41
- }
42
-
43
- async function loadAppPlacementDefinitions(logger) {
49
+ async function loadAppPlacementDefinitions(logger, asyncModuleRecoveryRuntime = null) {
44
50
  try {
45
51
  const moduleNamespace = await import("/src/placement.js");
46
52
  const exported = moduleNamespace?.default;
@@ -60,6 +66,9 @@ async function loadAppPlacementDefinitions(logger) {
60
66
  if (isMissingDynamicModule(error, APP_PLACEMENT_MODULE_SPECIFIER)) {
61
67
  return [];
62
68
  }
69
+ notifyDynamicImportFailure(asyncModuleRecoveryRuntime, error, {
70
+ label: "Placement config"
71
+ });
63
72
 
64
73
  logger.warn(
65
74
  {
@@ -73,7 +82,7 @@ async function loadAppPlacementDefinitions(logger) {
73
82
  return [];
74
83
  }
75
84
 
76
- async function loadAppPlacementTopology(logger) {
85
+ async function loadAppPlacementTopology(logger, asyncModuleRecoveryRuntime = null) {
77
86
  try {
78
87
  const moduleNamespace = await import("/src/placementTopology.js");
79
88
  const exported = moduleNamespace?.default;
@@ -82,6 +91,9 @@ async function loadAppPlacementTopology(logger) {
82
91
  if (isMissingDynamicModule(error, APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER)) {
83
92
  return [];
84
93
  }
94
+ notifyDynamicImportFailure(asyncModuleRecoveryRuntime, error, {
95
+ label: "Placement topology"
96
+ });
85
97
 
86
98
  logger.warn(
87
99
  {
@@ -127,7 +139,7 @@ function createErrorConfigToolkit(errorRuntime) {
127
139
  });
128
140
  }
129
141
 
130
- async function loadAppErrorConfig(logger, errorRuntime) {
142
+ async function loadAppErrorConfig(logger, errorRuntime, asyncModuleRecoveryRuntime = null) {
131
143
  try {
132
144
  const moduleNamespace = await import("/src/error.js");
133
145
  const exported = moduleNamespace?.default;
@@ -149,6 +161,9 @@ async function loadAppErrorConfig(logger, errorRuntime) {
149
161
  if (isMissingDynamicModule(error, APP_ERROR_MODULE_SPECIFIER)) {
150
162
  return {};
151
163
  }
164
+ notifyDynamicImportFailure(asyncModuleRecoveryRuntime, error, {
165
+ label: "Error config"
166
+ });
152
167
 
153
168
  logger.warn(
154
169
  {
@@ -362,6 +377,10 @@ function installRouterErrorBridge(app, errorRuntime, logger) {
362
377
  }
363
378
 
364
379
  router.onError((error) => {
380
+ if (isDynamicImportError(error)) {
381
+ return;
382
+ }
383
+
365
384
  try {
366
385
  errorRuntime.report({
367
386
  source: "shell-web.router.on-error",
@@ -384,6 +403,121 @@ function installRouterErrorBridge(app, errorRuntime, logger) {
384
403
  });
385
404
  }
386
405
 
406
+ function createShellAsyncModuleRecoveryRuntime({
407
+ app,
408
+ logger = null
409
+ } = {}) {
410
+ if (!app || typeof app.has !== "function" || typeof app.make !== "function") {
411
+ throw new Error("createShellAsyncModuleRecoveryRuntime requires application has()/make().");
412
+ }
413
+
414
+ const runtimeLogger = logger || createSharedProviderLogger(app);
415
+ const state = createAsyncModuleRecoveryState();
416
+ let installedRecovery = null;
417
+
418
+ function errorRuntime() {
419
+ if (!app.has("runtime.web-error.client")) {
420
+ return null;
421
+ }
422
+ const runtime = app.make("runtime.web-error.client");
423
+ return runtime && typeof runtime.report === "function" ? runtime : null;
424
+ }
425
+
426
+ function report(nextState = state) {
427
+ const runtime = errorRuntime();
428
+ if (!runtime) {
429
+ runtimeLogger.warn(
430
+ {
431
+ label: String(nextState?.label || "")
432
+ },
433
+ "Async module recovery could not report because the shell error runtime is unavailable."
434
+ );
435
+ return null;
436
+ }
437
+
438
+ try {
439
+ return runtime.report({
440
+ source: "shell-web.async-module-recovery",
441
+ message: String(nextState?.message || "A required app module could not load."),
442
+ cause: nextState?.error || null,
443
+ intent: "app-recoverable",
444
+ severity: "warning",
445
+ dedupeKey: `shell-web.async-module-recovery.${Number(nextState?.attempt || 0)}`,
446
+ action: {
447
+ label: "Reload",
448
+ dismissOnRun: true,
449
+ handler() {
450
+ return reload();
451
+ }
452
+ }
453
+ });
454
+ } catch (error) {
455
+ runtimeLogger.error(
456
+ {
457
+ label: String(nextState?.label || ""),
458
+ error: String(error?.message || error || "unknown error")
459
+ },
460
+ "Async module recovery could not report through the shell error runtime."
461
+ );
462
+ return null;
463
+ }
464
+ }
465
+
466
+ async function reload() {
467
+ const reloaded = await guardedReloadApp({
468
+ state,
469
+ label: "App",
470
+ message: ASYNC_MODULE_RELOAD_FAILURE_MESSAGE
471
+ });
472
+ if (!reloaded) {
473
+ report(state);
474
+ }
475
+ return reloaded;
476
+ }
477
+
478
+ function notify(error = null, {
479
+ label = "App module",
480
+ message = "",
481
+ stale = isDynamicImportError(error)
482
+ } = {}) {
483
+ notifyAsyncModuleLoadError(state, error, {
484
+ label,
485
+ message,
486
+ retry: state.retry,
487
+ stale
488
+ });
489
+ return report(state);
490
+ }
491
+
492
+ function install() {
493
+ if (installedRecovery) {
494
+ return installedRecovery;
495
+ }
496
+
497
+ installedRecovery = installAsyncModuleRecoveryHandlers({
498
+ state,
499
+ label: "App module",
500
+ router: app.has("jskit.client.router") ? app.make("jskit.client.router") : null,
501
+ onNotify: report
502
+ });
503
+ return installedRecovery;
504
+ }
505
+
506
+ function dispose() {
507
+ installedRecovery?.dispose?.();
508
+ installedRecovery = null;
509
+ }
510
+
511
+ return Object.freeze({
512
+ dispose,
513
+ install,
514
+ notify,
515
+ reload,
516
+ report,
517
+ state
518
+ });
519
+ }
520
+
387
521
  class ShellWebClientProvider {
388
522
  static id = "shell.web.client";
389
523
 
@@ -437,6 +571,12 @@ class ShellWebClientProvider {
437
571
  logger
438
572
  })
439
573
  );
574
+ app.singleton("runtime.web-async-module-recovery.client", (scope) =>
575
+ createShellAsyncModuleRecoveryRuntime({
576
+ app: scope,
577
+ logger
578
+ })
579
+ );
440
580
  app.singleton("runtime.web-error.presentation-store.client", () => createErrorPresentationStore());
441
581
  app.singleton("runtime.web-error.client", (scope) =>
442
582
  createErrorRuntime({
@@ -456,13 +596,17 @@ class ShellWebClientProvider {
456
596
  }
457
597
 
458
598
  const logger = createSharedProviderLogger(isRecord(app) ? app : null);
599
+ const errorRuntime = app.make("runtime.web-error.client");
600
+ const asyncModuleRecoveryRuntime = app.make("runtime.web-async-module-recovery.client");
601
+ asyncModuleRecoveryRuntime.install();
602
+
459
603
  const placementRuntime = app.make("runtime.web-placement.client");
460
604
  if (placementRuntime && typeof placementRuntime.replacePlacements === "function") {
461
- const placementTopology = await loadAppPlacementTopology(logger);
605
+ const placementTopology = await loadAppPlacementTopology(logger, asyncModuleRecoveryRuntime);
462
606
  if (typeof placementRuntime.replacePlacementTopology === "function") {
463
607
  placementRuntime.replacePlacementTopology(placementTopology, { source: APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER });
464
608
  }
465
- const placements = await loadAppPlacementDefinitions(logger);
609
+ const placements = await loadAppPlacementDefinitions(logger, asyncModuleRecoveryRuntime);
466
610
  placementRuntime.replacePlacements(placements, { source: APP_PLACEMENT_MODULE_SPECIFIER });
467
611
  const appConfig = getClientAppConfig();
468
612
  const surfaceRuntime = app.has("jskit.client.surface.runtime")
@@ -486,8 +630,7 @@ class ShellWebClientProvider {
486
630
  );
487
631
  }
488
632
 
489
- const errorRuntime = app.make("runtime.web-error.client");
490
- const errorConfig = await loadAppErrorConfig(logger, errorRuntime);
633
+ const errorConfig = await loadAppErrorConfig(logger, errorRuntime, asyncModuleRecoveryRuntime);
491
634
  applyAppErrorConfig(errorRuntime, errorConfig);
492
635
 
493
636
  const bootstrapRuntime = app.make("runtime.web-bootstrap.client");
@@ -514,6 +657,7 @@ class ShellWebClientProvider {
514
657
  vueApp.provide("jskit.shell-web.runtime.web-placement.client", placementRuntime);
515
658
  vueApp.provide("jskit.shell-web.runtime.web-refresh.client", refreshRuntime);
516
659
  vueApp.provide("jskit.shell-web.runtime.web-error.client", errorRuntime);
660
+ vueApp.provide(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY, asyncModuleRecoveryRuntime);
517
661
  vueApp.provide(
518
662
  "jskit.shell-web.runtime.web-error.presentation-store.client",
519
663
  errorPresentationStore
@@ -0,0 +1,29 @@
1
+ import {
2
+ isDynamicImportError
3
+ } from "@jskit-ai/kernel/client/asyncModuleRecovery";
4
+
5
+ function isMissingDynamicModule(error, moduleSpecifier) {
6
+ const message = String(error?.message || error || "");
7
+ return (
8
+ message.includes(moduleSpecifier) &&
9
+ (message.includes("Cannot find module") ||
10
+ message.includes("ERR_MODULE_NOT_FOUND"))
11
+ );
12
+ }
13
+
14
+ function notifyDynamicImportFailure(asyncModuleRecoveryRuntime, error, {
15
+ label = "App module"
16
+ } = {}) {
17
+ if (!isDynamicImportError(error) || typeof asyncModuleRecoveryRuntime?.notify !== "function") {
18
+ return;
19
+ }
20
+
21
+ asyncModuleRecoveryRuntime.notify(error, {
22
+ label
23
+ });
24
+ }
25
+
26
+ export {
27
+ isMissingDynamicModule,
28
+ notifyDynamicImportFailure
29
+ };
@@ -19,7 +19,7 @@
19
19
 
20
20
  <style scoped>
21
21
  .generated-ui-screen {
22
- --generated-ui-screen-title-size: clamp(1.5rem, 3vw, 2.5rem);
22
+ --generated-ui-screen-title-size: 2rem;
23
23
  --generated-ui-screen-panel-padding: 1rem;
24
24
  }
25
25
 
@@ -31,7 +31,7 @@
31
31
  .home-start-screen__title {
32
32
  font-size: var(--generated-ui-screen-title-size);
33
33
  font-weight: 700;
34
- letter-spacing: -0.03em;
34
+ letter-spacing: 0;
35
35
  line-height: 1.08;
36
36
  margin: 0 0 0.45rem;
37
37
  }
@@ -39,4 +39,10 @@
39
39
  .home-start-screen__panel {
40
40
  padding: var(--generated-ui-screen-panel-padding);
41
41
  }
42
+
43
+ @media (max-width: 640px) {
44
+ .generated-ui-screen {
45
+ --generated-ui-screen-title-size: 1.5rem;
46
+ }
47
+ }
42
48
  </style>
@@ -56,7 +56,7 @@ const health = computed(() => {
56
56
 
57
57
  <style scoped>
58
58
  .generated-ui-screen {
59
- --generated-ui-screen-title-size: clamp(1.5rem, 2.5vw, 2.25rem);
59
+ --generated-ui-screen-title-size: 2rem;
60
60
  --generated-ui-screen-panel-padding: 1rem;
61
61
  }
62
62
 
@@ -70,7 +70,7 @@ const health = computed(() => {
70
70
  .home-surface-screen__title {
71
71
  font-size: var(--generated-ui-screen-title-size);
72
72
  font-weight: 700;
73
- letter-spacing: -0.03em;
73
+ letter-spacing: 0;
74
74
  line-height: 1.1;
75
75
  margin: 0 0 0.4rem;
76
76
  }
@@ -90,6 +90,10 @@ const health = computed(() => {
90
90
  }
91
91
 
92
92
  @media (max-width: 640px) {
93
+ .generated-ui-screen {
94
+ --generated-ui-screen-title-size: 1.5rem;
95
+ }
96
+
93
97
  .home-surface-screen__header {
94
98
  flex-direction: column;
95
99
  }
@@ -29,14 +29,14 @@ import { RouterView } from "vue-router";
29
29
 
30
30
  <style scoped>
31
31
  .generated-ui-screen {
32
- --generated-ui-screen-title-size: clamp(1.35rem, 2vw, 1.85rem);
32
+ --generated-ui-screen-title-size: 1.85rem;
33
33
  --generated-ui-screen-panel-padding: 1rem;
34
34
  }
35
35
 
36
36
  .settings-shell__title {
37
37
  font-size: var(--generated-ui-screen-title-size);
38
38
  font-weight: 650;
39
- letter-spacing: -0.02em;
39
+ letter-spacing: 0;
40
40
  line-height: 1.15;
41
41
  margin: 0 0 0.35rem;
42
42
  }
@@ -81,6 +81,10 @@ import { RouterView } from "vue-router";
81
81
  }
82
82
 
83
83
  @media (max-width: 960px) {
84
+ .generated-ui-screen {
85
+ --generated-ui-screen-title-size: 1.35rem;
86
+ }
87
+
84
88
  .settings-shell__body {
85
89
  grid-template-columns: 1fr;
86
90
  }
@@ -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
+ });
@@ -7,7 +7,7 @@ import descriptor from "../package.descriptor.mjs";
7
7
 
8
8
  const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
9
9
  const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
10
- const CREATE_APP_TEMPLATE_DIR = path.resolve(PACKAGE_DIR, "..", "..", "tooling", "create-app", "templates", "base-shell");
10
+ const CREATE_APP_TEMPLATE_DIR = path.resolve(PACKAGE_DIR, "..", "..", "tooling", "create-app", "templates", "minimal-shell");
11
11
 
12
12
  function findFileMutation(id) {
13
13
  const files = descriptor?.mutations?.files;
@@ -48,7 +48,7 @@ test("shell-web claims starter shell files as app-owned scaffolds", () => {
48
48
  });
49
49
  });
50
50
 
51
- test("shell-web expected-existing starter files stay aligned with create-app base-shell", async () => {
51
+ test("shell-web expected-existing starter files stay aligned with create-app minimal-shell", async () => {
52
52
  const comparedFiles = [
53
53
  "src/App.vue",
54
54
  "src/pages/home.vue",
@@ -5,9 +5,31 @@ import {
5
5
  ShellWebClientProvider,
6
6
  resolveAppPlacementTopologyExport
7
7
  } from "../src/client/providers/ShellWebClientProvider.js";
8
+ import {
9
+ isMissingDynamicModule
10
+ } from "../src/client/providers/appModuleLoadFailure.js";
11
+ import {
12
+ SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY
13
+ } from "../src/client/asyncModuleRecovery/index.js";
8
14
  import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
9
15
  const CLIENT_APP_CONFIG_GLOBAL_KEY = "__JSKIT_CLIENT_APP_CONFIG__";
10
16
 
17
+ async function withTemporaryGlobal(name, value, callback) {
18
+ const hadPrevious = Object.prototype.hasOwnProperty.call(globalThis, name);
19
+ const previousValue = globalThis[name];
20
+ globalThis[name] = value;
21
+
22
+ try {
23
+ return await callback();
24
+ } finally {
25
+ if (hadPrevious) {
26
+ globalThis[name] = previousValue;
27
+ } else {
28
+ delete globalThis[name];
29
+ }
30
+ }
31
+ }
32
+
11
33
  function setClientAppConfig(source = {}) {
12
34
  const normalized =
13
35
  source && typeof source === "object" && !Array.isArray(source) ? Object.freeze({ ...source }) : Object.freeze({});
@@ -17,7 +39,7 @@ function setClientAppConfig(source = {}) {
17
39
  return normalized;
18
40
  }
19
41
 
20
- function createAppDouble({ surfaceRuntime = null, queryClient = null } = {}) {
42
+ function createAppDouble({ surfaceRuntime = null, queryClient = null, router = null } = {}) {
21
43
  const singletons = new Map();
22
44
  const singletonInstances = new Map();
23
45
  const provided = [];
@@ -67,6 +89,9 @@ function createAppDouble({ surfaceRuntime = null, queryClient = null } = {}) {
67
89
  if (token === "jskit.client.query-client") {
68
90
  return Boolean(queryClient);
69
91
  }
92
+ if (token === "jskit.client.router") {
93
+ return Boolean(router);
94
+ }
70
95
  return singletons.has(token) || singletonInstances.has(token);
71
96
  },
72
97
  make(token) {
@@ -82,6 +107,9 @@ function createAppDouble({ surfaceRuntime = null, queryClient = null } = {}) {
82
107
  if (token === "jskit.client.query-client" && queryClient) {
83
108
  return queryClient;
84
109
  }
110
+ if (token === "jskit.client.router" && router) {
111
+ return router;
112
+ }
85
113
  if (singletonInstances.has(token)) {
86
114
  return singletonInstances.get(token);
87
115
  }
@@ -100,38 +128,45 @@ function createAppDouble({ surfaceRuntime = null, queryClient = null } = {}) {
100
128
  }
101
129
 
102
130
  async function withFetchStub(responsePayload, callback) {
103
- const previousFetch = globalThis.fetch;
104
- globalThis.fetch = async () => ({
131
+ return withFetchImplementation(async () => ({
105
132
  ok: true,
106
133
  async json() {
107
134
  return responsePayload;
108
135
  }
109
- });
110
-
111
- try {
112
- return await callback();
113
- } finally {
114
- if (previousFetch === undefined) {
115
- delete globalThis.fetch;
116
- } else {
117
- globalThis.fetch = previousFetch;
118
- }
119
- }
136
+ }), callback);
120
137
  }
121
138
 
122
139
  async function withFetchImplementation(fetchImplementation, callback) {
123
- const previousFetch = globalThis.fetch;
124
- globalThis.fetch = fetchImplementation;
140
+ return withTemporaryGlobal("fetch", fetchImplementation, callback);
141
+ }
125
142
 
126
- try {
127
- return await callback();
128
- } finally {
129
- if (previousFetch === undefined) {
130
- delete globalThis.fetch;
131
- } else {
132
- globalThis.fetch = previousFetch;
143
+ async function withWindowDouble(windowObject, callback) {
144
+ return withTemporaryGlobal("window", windowObject, callback);
145
+ }
146
+
147
+ function createWindowDouble({ href = "https://example.test/home" } = {}) {
148
+ const listeners = new Map();
149
+ const reloadCalls = [];
150
+ const location = {
151
+ href,
152
+ reload() {
153
+ reloadCalls.push(location.href);
133
154
  }
134
- }
155
+ };
156
+
157
+ return {
158
+ listeners,
159
+ reloadCalls,
160
+ location,
161
+ addEventListener(type, handler) {
162
+ listeners.set(type, handler);
163
+ },
164
+ removeEventListener(type, handler) {
165
+ if (listeners.get(type) === handler) {
166
+ listeners.delete(type);
167
+ }
168
+ }
169
+ };
135
170
  }
136
171
 
137
172
  test("shell web client provider preserves append-only placement topology object exports", () => {
@@ -160,6 +195,23 @@ test("shell web client provider preserves append-only placement topology object
160
195
  assert.deepEqual(warnings, []);
161
196
  });
162
197
 
198
+ test("shell web boot import classifier does not swallow fetched dynamic module failures", () => {
199
+ assert.equal(
200
+ isMissingDynamicModule(
201
+ new Error("Cannot find module '/src/placement.js' imported from /src/main.js"),
202
+ "/src/placement.js"
203
+ ),
204
+ true
205
+ );
206
+ assert.equal(
207
+ isMissingDynamicModule(
208
+ new Error("Failed to fetch dynamically imported module: http://localhost:5173/src/placement.js?t=123"),
209
+ "/src/placement.js"
210
+ ),
211
+ false
212
+ );
213
+ });
214
+
163
215
  test("shell web client provider binds runtime and injects it into Vue app", async () => {
164
216
  await withFetchStub({ surfaceAccess: {} }, async () => {
165
217
  const app = createAppDouble();
@@ -170,6 +222,7 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
170
222
  assert.equal(app.singletons.has("runtime.web-error.client"), true);
171
223
  assert.equal(app.singletons.has("runtime.web-error.presentation-store.client"), true);
172
224
  assert.equal(app.singletons.has("runtime.web-refresh.client"), true);
225
+ assert.equal(app.singletons.has("runtime.web-async-module-recovery.client"), true);
173
226
 
174
227
  await provider.boot(app);
175
228
  assert.equal(app.plugins.length, 0);
@@ -179,6 +232,7 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
179
232
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-placement.client"), true);
180
233
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-refresh.client"), true);
181
234
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.client"), true);
235
+ assert.equal(providedByKey.has(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY), true);
182
236
  assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.presentation-store.client"), true);
183
237
 
184
238
  const placementRuntime = providedByKey.get("jskit.shell-web.runtime.web-placement.client");
@@ -194,6 +248,11 @@ test("shell web client provider binds runtime and injects it into Vue app", asyn
194
248
  const refreshRuntime = providedByKey.get("jskit.shell-web.runtime.web-refresh.client");
195
249
  assert.equal(typeof refreshRuntime.refresh, "function");
196
250
 
251
+ const asyncModuleRecoveryRuntime = providedByKey.get(SHELL_ASYNC_MODULE_RECOVERY_RUNTIME_KEY);
252
+ assert.equal(typeof asyncModuleRecoveryRuntime.install, "function");
253
+ assert.equal(typeof asyncModuleRecoveryRuntime.notify, "function");
254
+ assert.equal(typeof asyncModuleRecoveryRuntime.reload, "function");
255
+
197
256
  const errorStore = providedByKey.get("jskit.shell-web.runtime.web-error.presentation-store.client");
198
257
  assert.equal(typeof errorStore.getState, "function");
199
258
  assert.equal(typeof errorStore.present, "function");
@@ -260,6 +319,127 @@ test("shell refresh runtime reports recoverable retry errors as banners", async
260
319
  });
261
320
  });
262
321
 
322
+ test("shell web client provider reports dynamic import failures through async module recovery", async () => {
323
+ await withFetchStub({ surfaceAccess: {} }, async () => {
324
+ const routerErrorHandlers = [];
325
+ const replacedPaths = [];
326
+ const router = {
327
+ onError(handler) {
328
+ routerErrorHandlers.push(handler);
329
+ return () => null;
330
+ },
331
+ replace(fullPath) {
332
+ replacedPaths.push(fullPath);
333
+ }
334
+ };
335
+ const app = createAppDouble({ router });
336
+ const provider = new ShellWebClientProvider();
337
+ provider.register(app);
338
+ await provider.boot(app);
339
+
340
+ assert.equal(routerErrorHandlers.length, 2);
341
+
342
+ const chunkError = new Error("Failed to fetch dynamically imported module: /assets/page.js");
343
+ for (const handler of routerErrorHandlers) {
344
+ handler(chunkError, {
345
+ fullPath: "/app/dashboard"
346
+ });
347
+ }
348
+
349
+ const errorStore = app.make("runtime.web-error.presentation-store.client");
350
+ const state = errorStore.getState();
351
+ assert.equal(state.channels.banner.length, 1);
352
+ assert.equal(
353
+ state.channels.banner[0].message,
354
+ "Page did not download. The app may have been updated, or the network request failed."
355
+ );
356
+ assert.equal(state.channels.banner[0].severity, "warning");
357
+ assert.equal(state.channels.banner[0].action.label, "Reload");
358
+ assert.deepEqual(replacedPaths, []);
359
+ });
360
+ });
361
+
362
+ test("shell web async module recovery reload action checks the document before reloading", async () => {
363
+ const windowObject = createWindowDouble();
364
+ await withWindowDouble(windowObject, async () => {
365
+ const fetchCalls = [];
366
+ await withFetchImplementation(async (input, init = {}) => {
367
+ fetchCalls.push({ input, init });
368
+ return {
369
+ ok: true,
370
+ status: 200,
371
+ async json() {
372
+ return { surfaceAccess: {} };
373
+ }
374
+ };
375
+ }, async () => {
376
+ const routerErrorHandlers = [];
377
+ const router = {
378
+ onError(handler) {
379
+ routerErrorHandlers.push(handler);
380
+ return () => null;
381
+ },
382
+ replace() {}
383
+ };
384
+ const app = createAppDouble({ router });
385
+ const provider = new ShellWebClientProvider();
386
+ provider.register(app);
387
+ await provider.boot(app);
388
+
389
+ const chunkError = new Error("Failed to fetch dynamically imported module: /assets/page.js");
390
+ routerErrorHandlers[0](chunkError, {
391
+ fullPath: "/home/settings"
392
+ });
393
+
394
+ const errorStore = app.make("runtime.web-error.presentation-store.client");
395
+ const banner = errorStore.getState().channels.banner[0];
396
+ await banner.action.handler();
397
+
398
+ assert.deepEqual(windowObject.reloadCalls, ["https://example.test/home"]);
399
+ const reloadFetch = fetchCalls.find((entry) => entry.input === "https://example.test/home");
400
+ assert.equal(reloadFetch?.init?.cache, "no-store");
401
+ assert.equal(reloadFetch?.init?.credentials, "same-origin");
402
+ });
403
+ });
404
+ });
405
+
406
+ test("shell web async module recovery keeps the page alive when reload check fails", async () => {
407
+ const windowObject = createWindowDouble();
408
+ await withWindowDouble(windowObject, async () => {
409
+ await withFetchImplementation(async (input) => {
410
+ if (input === "https://example.test/home") {
411
+ throw new TypeError("Failed to fetch");
412
+ }
413
+ return {
414
+ ok: true,
415
+ status: 200,
416
+ async json() {
417
+ return { surfaceAccess: {} };
418
+ }
419
+ };
420
+ }, async () => {
421
+ const app = createAppDouble();
422
+ const provider = new ShellWebClientProvider();
423
+ provider.register(app);
424
+ await provider.boot(app);
425
+
426
+ const recoveryRuntime = app.make("runtime.web-async-module-recovery.client");
427
+ const reloaded = await recoveryRuntime.reload();
428
+
429
+ assert.equal(reloaded, false);
430
+ assert.deepEqual(windowObject.reloadCalls, []);
431
+ const errorStore = app.make("runtime.web-error.presentation-store.client");
432
+ const state = errorStore.getState();
433
+ assert.equal(state.channels.banner.length, 1);
434
+ assert.equal(
435
+ state.channels.banner[0].message,
436
+ "The app cannot reload because the app server is not reachable. Restart the server, then click Reload."
437
+ );
438
+ assert.equal(state.channels.banner[0].action.label, "Reload");
439
+ });
440
+ });
441
+ });
442
+
263
443
  test("shell web client provider resolves surface config from client app config", async () => {
264
444
  setClientAppConfig({
265
445
  tenancyMode: "workspaces",
@@ -38,6 +38,11 @@ function readTopology(id = "", owner = "") {
38
38
  : [];
39
39
  }
40
40
 
41
+ function readClientContainerTokens() {
42
+ const tokens = descriptor?.metadata?.apiSummary?.containerTokens?.client;
43
+ return Array.isArray(tokens) ? tokens : [];
44
+ }
45
+
41
46
  function findFileMutation(id) {
42
47
  const files = descriptor?.mutations?.files;
43
48
  return Array.isArray(files)
@@ -143,6 +148,29 @@ test("shell-web installs generated adaptive shell Playwright smoke coverage", as
143
148
  assert.equal(packageJson?.exports?.["./test/adaptiveShellSmoke"], "./src/test/adaptiveShellSmoke.js");
144
149
  });
145
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
+
146
174
  test("shell-web route transition keeps mobile route motion placement-driven", async () => {
147
175
  const source = await readFile(
148
176
  path.join(PACKAGE_DIR, "src", "client", "components", "ShellRouteTransition.vue"),
@@ -265,6 +293,15 @@ test("shell-web placement topology seeds global actions as a semantic shell plac
265
293
  });
266
294
 
267
295
  test("shell-web descriptor metadata advertises adaptive shell outlets, default links, and installs the scaffold page", () => {
296
+ assert.deepEqual(readClientContainerTokens(), [
297
+ "runtime.web-placement.client",
298
+ "runtime.web-bootstrap.client",
299
+ "runtime.web-refresh.client",
300
+ "runtime.web-async-module-recovery.client",
301
+ "runtime.web-error.client",
302
+ "runtime.web-error.presentation-store.client"
303
+ ]);
304
+
268
305
  assert.deepEqual(
269
306
  readOutlets("shell-layout:primary-bottom-nav"),
270
307
  [