@jskit-ai/kernel 0.1.244 → 0.1.246

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.
Files changed (101) hide show
  1. package/client/appConfig.js +2 -1
  2. package/client/asyncModuleRecovery.js +2 -4
  3. package/client/index.d.ts +18 -76
  4. package/client/index.js +7 -2
  5. package/client/logging.js +8 -2
  6. package/client/moduleBootstrap.js +41 -33
  7. package/client/moduleBootstrap.test.js +35 -0
  8. package/client/shellBootstrap.js +38 -10
  9. package/client/shellBootstrap.test.js +85 -6
  10. package/client/shellRouting.js +16 -61
  11. package/client/shellRouting.test.js +30 -1
  12. package/client/vite/clientBootstrapPlugin.js +0 -6
  13. package/client/vite/clientBootstrapPlugin.test.js +2 -3
  14. package/internal/node/installedPackages.js +14 -58
  15. package/package.json +2 -4
  16. package/server/actions/actionCatalogue.js +13 -34
  17. package/{shared → server}/actions/actionContributorHelpers.js +0 -5
  18. package/{shared → server}/actions/actionDefinitions.js +24 -131
  19. package/{shared → server}/actions/actionDefinitions.test.js +75 -21
  20. package/server/actions/actionEvents.js +97 -2
  21. package/server/actions/actionEvents.test.js +79 -0
  22. package/{shared → server}/actions/executionContext.js +3 -13
  23. package/server/actions/idempotency.js +24 -0
  24. package/server/actions/pipeline.js +87 -0
  25. package/{shared → server}/actions/policies.js +20 -33
  26. package/{shared → server}/actions/policies.test.js +6 -6
  27. package/{shared → server}/actions/registry.js +3 -22
  28. package/{shared → server}/actions/registry.test.js +70 -32
  29. package/server/actions/requestMeta.js +17 -0
  30. package/server/http/capabilityHttpRuntime.js +1 -1
  31. package/server/http/capabilityHttpRuntime.test.js +56 -0
  32. package/server/http/errorHandling.js +291 -0
  33. package/server/{runtime/fastifyBootstrap.test.js → http/errorHandling.test.js} +72 -120
  34. package/server/http/lib/routeRegistration.js +36 -150
  35. package/server/http/lib/routeTransport.js +2 -0
  36. package/server/http/lib/routeValidator.js +5 -47
  37. package/server/http/lib/routeValidator.test.js +15 -17
  38. package/server/http/lib/router.js +10 -94
  39. package/server/platform/providerRuntime/capabilityProviderLoader.js +2 -1
  40. package/server/platform/providerRuntime/helpers.js +1 -34
  41. package/server/runtime/integers.js +1 -5
  42. package/server/runtime/serviceAuthorization.js +4 -9
  43. package/server/support/appConfig.js +2 -1
  44. package/server/support/appConfigFiles.js +1 -38
  45. package/server/support/appConfigFiles.test.js +3 -39
  46. package/server/support/index.js +1 -20
  47. package/shared/capabilities/defineProvider.js +1 -1
  48. package/shared/capabilities/index.js +1 -1
  49. package/shared/index.js +1 -1
  50. package/shared/support/index.js +1 -4
  51. package/shared/support/mobileConfig.js +99 -0
  52. package/shared/support/mobileConfig.test.js +113 -0
  53. package/shared/support/normalize.js +0 -108
  54. package/shared/support/normalize.test.js +0 -134
  55. package/shared/{actions/actionContributorHelpers.test.js → support/permissions.test.js} +1 -1
  56. package/shared/support/returnToPath.js +16 -20
  57. package/shared/support/returnToPath.test.js +5 -33
  58. package/shared/support/shellLayoutTargets.js +2 -140
  59. package/shared/support/shellLayoutTargets.test.js +1 -110
  60. package/shared/support/sorting.js +1 -9
  61. package/shared/surface/apiPaths.js +1 -82
  62. package/shared/surface/paths.js +38 -171
  63. package/shared/surface/registry.js +2 -1
  64. package/shared/surface/runtime.js +18 -109
  65. package/shared/surface/runtime.test.js +57 -0
  66. package/shared/validators/index.js +1 -4
  67. package/shared/validators/jsonRestSchemaSupport.js +0 -9
  68. package/shared/validators/recordIdParamsValidator.js +0 -6
  69. package/shared/validators/schemaDefinitions.js +4 -30
  70. package/shared/validators/schemaPayloadValidation.js +2 -6
  71. package/test/barrelExposure.test.js +5 -8
  72. package/test/exportsContract.test.js +1 -3
  73. package/client/mobileLaunchRouting.js +0 -231
  74. package/client/mobileLaunchRouting.test.js +0 -230
  75. package/server/http/lib/controller.js +0 -183
  76. package/server/http/lib/controller.test.js +0 -143
  77. package/server/http/lib/index.js +0 -5
  78. package/server/runtime/canonicalJson.js +0 -74
  79. package/server/runtime/entityChangeEvents.js +0 -307
  80. package/server/runtime/entityChangeEvents.test.js +0 -274
  81. package/server/runtime/fastifyBootstrap.js +0 -553
  82. package/server/runtime/moduleConfig.js +0 -223
  83. package/server/runtime/moduleConfig.test.js +0 -165
  84. package/server/runtime/pagination.js +0 -13
  85. package/server/runtime/realtimeNormalization.js +0 -21
  86. package/server/runtime/routeUtils.js +0 -20
  87. package/server/runtime/securityAudit.js +0 -269
  88. package/server/runtime/securityAudit.test.js +0 -41
  89. package/server/support/pageTargets.js +0 -772
  90. package/server/support/pageTargets.test.js +0 -659
  91. package/server/support/shellOutlets.js +0 -576
  92. package/server/support/shellOutlets.test.js +0 -336
  93. package/server/surface/index.js +0 -18
  94. package/shared/actions/audit.js +0 -7
  95. package/shared/actions/idempotency.js +0 -62
  96. package/shared/actions/observability.js +0 -10
  97. package/shared/actions/pipeline.js +0 -309
  98. package/shared/actions/requestMeta.js +0 -36
  99. package/shared/actions/textNormalization.js +0 -3
  100. package/shared/actions/withActionDefaults.js +0 -34
  101. /package/{shared → server}/actions/executionContext.test.js +0 -0
@@ -1,4 +1,5 @@
1
- import { isRecord, normalizeMobileConfig } from "../shared/support/normalize.js";
1
+ import { isRecord } from "../shared/support/normalize.js";
2
+ import { normalizeMobileConfig } from "../shared/support/mobileConfig.js";
2
3
 
3
4
  const CLIENT_APP_CONFIG_GLOBAL_KEY = "__JSKIT_CLIENT_APP_CONFIG__";
4
5
  const EMPTY_CLIENT_APP_CONFIG = Object.freeze({});
@@ -1,3 +1,5 @@
1
+ import { isRecord } from "../shared/support/normalize.js";
2
+
1
3
  const DYNAMIC_IMPORT_ERROR_PATTERNS = Object.freeze([
2
4
  /ChunkLoadError/iu,
3
5
  /Failed to fetch dynamically imported module/iu,
@@ -7,10 +9,6 @@ const DYNAMIC_IMPORT_ERROR_PATTERNS = Object.freeze([
7
9
  /Unable to preload CSS/iu
8
10
  ]);
9
11
 
10
- function isRecord(value) {
11
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
12
- }
13
-
14
12
  function errorText(error = null) {
15
13
  return String(error?.message || error || "").trim();
16
14
  }
package/client/index.d.ts CHANGED
@@ -6,100 +6,40 @@ export type ClientLogger = {
6
6
  isDebugEnabled?: boolean;
7
7
  };
8
8
 
9
- export type AsyncModuleRecoveryState = {
10
- attempt: number;
11
- error: any;
12
- label: string;
13
- message: string;
14
- retry: (() => any) | null;
15
- stale: boolean;
16
- visible: boolean;
17
- };
18
-
19
- export function createAsyncModuleRecoveryState(options?: {
20
- label?: string;
21
- message?: string;
22
- retry?: (() => any) | null;
23
- }): AsyncModuleRecoveryState;
24
-
25
- export function isDynamicImportError(error?: any): boolean;
26
-
27
- export function dynamicImportErrorMessage(error?: any, options?: {
28
- label?: string;
29
- stale?: boolean;
30
- }): string;
9
+ export * from "./asyncModuleRecovery";
31
10
 
32
- export function notifyAsyncModuleLoadError(
33
- state: AsyncModuleRecoveryState,
34
- error?: any,
35
- options?: {
36
- label?: string;
37
- message?: string;
38
- retry?: (() => any) | null;
39
- stale?: boolean;
40
- }
41
- ): AsyncModuleRecoveryState;
42
-
43
- export function dismissAsyncModuleRecovery(state: AsyncModuleRecoveryState): boolean;
44
-
45
- export function guardedReloadApp(options?: {
46
- browserWindow?: any;
47
- fetchFn?: ((input: string, init?: Record<string, any>) => Promise<any>) | null;
48
- state?: AsyncModuleRecoveryState | null;
49
- label?: string;
50
- message?: string;
51
- }): Promise<boolean>;
52
-
53
- export function installAsyncModuleRecoveryHandlers(options?: {
54
- router?: any;
55
- state: AsyncModuleRecoveryState;
56
- label?: string;
57
- onNotify?: (state: AsyncModuleRecoveryState) => void;
58
- windowObject?: any;
59
- }): Readonly<{
60
- dispose: () => void;
61
- }>;
11
+ export const DEFAULT_GUARD_EVALUATOR_KEY: "__JSKIT_WEB_SHELL_GUARD_EVALUATOR__";
62
12
 
63
13
  export function getClientAppConfig(): Readonly<Record<string, any>>;
64
14
  export function resolveMobileConfig(appConfig?: Record<string, any>): Readonly<Record<string, any>>;
65
15
  export function resolveClientAssetMode(appConfig?: Record<string, any>): string;
66
- export function normalizeIncomingAppUrl(
67
- url?: string,
68
- mobileConfig?: Record<string, any>,
69
- options?: {
70
- currentOrigin?: string;
71
- allowedHttpOrigins?: string[];
72
- }
73
- ): string;
74
- export function registerMobileLaunchRouting(options?: {
75
- router: any;
76
- mobileConfig?: Record<string, any>;
77
- getInitialLaunchUrl?: () => Promise<string> | string;
78
- subscribeToLaunchUrls?: (handler: (url: string) => void) => (() => void) | void;
79
- currentOrigin?: string;
80
- allowedHttpOrigins?: string[];
81
- logger?: ClientLogger;
82
- }): Readonly<{
83
- initialize: () => Promise<string>;
84
- dispose: () => void;
85
- applyIncomingUrl: (url?: string, reason?: string) => Promise<string>;
86
- }>;
87
-
88
16
  export function resolveClientBootstrapDebugEnabled(options?: {
89
17
  env?: Record<string, any>;
90
18
  debugEnabled?: boolean;
91
19
  debugEnvKey?: string;
92
20
  }): boolean;
93
21
 
22
+ export function shellScrollBehavior(to: any, from: any, savedPosition: any): any;
23
+
94
24
  export function createShellRouter(options?: {
95
- createRouter: (options: { history?: any; routes: any[] }) => any;
25
+ createRouter: (options: { history?: any; routes: any[]; scrollBehavior?: (to: any, from: any, savedPosition: any) => any }) => any;
96
26
  history?: any;
97
27
  routes?: any[];
98
28
  surfaceRuntime: any;
99
29
  surfaceMode?: string;
100
30
  fallbackRoute?: any;
101
31
  notFoundComponent?: any;
102
- guard?: false | ((to: any) => any) | Record<string, any>;
32
+ guard?:
33
+ | false
34
+ | ((to: any) => any)
35
+ | {
36
+ webRootAllowed?: "yes" | "no";
37
+ guardEvaluatorKey?: string;
38
+ authenticatedPolicy?: string;
39
+ publicPolicy?: string;
40
+ };
41
+ /** Defaults to shellScrollBehavior; pass your own function, or false for none. */
42
+ scrollBehavior?: false | ((to: any, from: any, savedPosition: any) => any);
103
43
  }): Readonly<{
104
44
  router: any;
105
45
  activeRoutes: readonly any[];
@@ -112,6 +52,7 @@ export function bootstrapClientShellApp(options?: {
112
52
  appConfig?: Record<string, any>;
113
53
  appPlugins?: any[];
114
54
  pinia?: any;
55
+ queryClient?: any;
115
56
  router: any;
116
57
  bootClientModules: (context: any) => Promise<any>;
117
58
  surfaceRuntime: any;
@@ -119,6 +60,7 @@ export function bootstrapClientShellApp(options?: {
119
60
  env?: Record<string, any>;
120
61
  fallbackRoute?: any;
121
62
  logger?: ClientLogger;
63
+ /** Its isDebugEnabled decides whether bootstrap builds debug payloads; without it, a debug method means enabled. */
122
64
  createBootstrapLogger?: (options: {
123
65
  env?: Record<string, any>;
124
66
  logger?: ClientLogger;
package/client/index.js CHANGED
@@ -8,6 +8,11 @@ export {
8
8
  isDynamicImportError,
9
9
  notifyAsyncModuleLoadError
10
10
  } from "./asyncModuleRecovery.js";
11
- export { normalizeIncomingAppUrl, registerMobileLaunchRouting } from "./mobileLaunchRouting.js";
12
- export { resolveClientBootstrapDebugEnabled, createSurfaceShellRouter as createShellRouter, bootstrapClientShellApp } from "./shellBootstrap.js";
11
+ export {
12
+ resolveClientBootstrapDebugEnabled,
13
+ createSurfaceShellRouter as createShellRouter,
14
+ shellScrollBehavior,
15
+ bootstrapClientShellApp
16
+ } from "./shellBootstrap.js";
17
+ export { DEFAULT_GUARD_EVALUATOR_KEY } from "./shellRouting.js";
13
18
  export { createComponentInteractionEmitter } from "./componentInteraction.js";
package/client/logging.js CHANGED
@@ -1,12 +1,17 @@
1
1
  import { isRecord } from "../shared/support/normalize.js";
2
2
 
3
+ // isDebugEnabled tells callers whether debug output goes anywhere, so they
4
+ // can skip building debug payloads. A logger may state it; otherwise it is
5
+ // true exactly when the logger has its own debug method.
3
6
  function createStructuredLogger(logger = console) {
4
7
  if (isRecord(logger)) {
8
+ const hasDebug = typeof logger.debug === "function";
5
9
  return Object.freeze({
6
10
  info: typeof logger.info === "function" ? logger.info.bind(logger) : console.info.bind(console),
7
11
  warn: typeof logger.warn === "function" ? logger.warn.bind(logger) : console.warn.bind(console),
8
12
  error: typeof logger.error === "function" ? logger.error.bind(logger) : console.error.bind(console),
9
- debug: typeof logger.debug === "function" ? logger.debug.bind(logger) : () => {}
13
+ debug: hasDebug ? logger.debug.bind(logger) : () => {},
14
+ isDebugEnabled: typeof logger.isDebugEnabled === "boolean" ? logger.isDebugEnabled : hasDebug
10
15
  });
11
16
  }
12
17
 
@@ -14,7 +19,8 @@ function createStructuredLogger(logger = console) {
14
19
  info: console.info.bind(console),
15
20
  warn: console.warn.bind(console),
16
21
  error: console.error.bind(console),
17
- debug: () => {}
22
+ debug: () => {},
23
+ isDebugEnabled: false
18
24
  });
19
25
  }
20
26
 
@@ -136,16 +136,18 @@ function registerClientModuleRoutes({
136
136
  surfaceMode
137
137
  });
138
138
  const log = createStructuredLogger(logger);
139
- log.debug(
140
- {
141
- packageId: normalizedPackageId,
142
- source,
143
- surfaceMode: String(surfaceMode || "").trim(),
144
- declaredRoutes: normalizedRoutes.map(toRouteSnapshot),
145
- activeRoutes: activeRoutes.map(toRouteSnapshot)
146
- },
147
- "Client route registration analysis."
148
- );
139
+ if (log.isDebugEnabled) {
140
+ log.debug(
141
+ {
142
+ packageId: normalizedPackageId,
143
+ source,
144
+ surfaceMode: String(surfaceMode || "").trim(),
145
+ declaredRoutes: normalizedRoutes.map(toRouteSnapshot),
146
+ activeRoutes: activeRoutes.map(toRouteSnapshot)
147
+ },
148
+ "Client route registration analysis."
149
+ );
150
+ }
149
151
 
150
152
  const declaredPaths = normalizedRoutes.map((route) => route.path);
151
153
  const activePaths = activeRoutes.map((route) => route.path);
@@ -167,14 +169,16 @@ function registerClientModuleRoutes({
167
169
 
168
170
  seenRoutePaths.add(normalizedPath);
169
171
  router.addRoute(toVueRouteRecord(route));
170
- log.debug(
171
- {
172
- packageId: normalizedPackageId,
173
- source,
174
- route: toRouteSnapshot(route)
175
- },
176
- "Added client route to router."
177
- );
172
+ if (log.isDebugEnabled) {
173
+ log.debug(
174
+ {
175
+ packageId: normalizedPackageId,
176
+ source,
177
+ route: toRouteSnapshot(route)
178
+ },
179
+ "Added client route to router."
180
+ );
181
+ }
178
182
  registeredCount += 1;
179
183
  }
180
184
 
@@ -560,14 +564,16 @@ async function bootClientModules({
560
564
  },
561
565
  "Registered client module routes."
562
566
  );
563
- log.debug(
564
- {
565
- packageId,
566
- source,
567
- routerRoutes: summarizeRouterRoutes(router)
568
- },
569
- "Router route table after client route registration."
570
- );
567
+ if (log.isDebugEnabled) {
568
+ log.debug(
569
+ {
570
+ packageId,
571
+ source,
572
+ routerRoutes: summarizeRouterRoutes(router)
573
+ },
574
+ "Router route table after client route registration."
575
+ );
576
+ }
571
577
  routeResults.push(result);
572
578
  return result;
573
579
  };
@@ -599,13 +605,15 @@ async function bootClientModules({
599
605
  },
600
606
  "Booted JSKIT client modules."
601
607
  );
602
- log.debug(
603
- {
604
- routerRoutes: summarizeRouterRoutes(router),
605
- currentPath: typeof window !== "undefined" ? String(window.location?.pathname || "") : ""
606
- },
607
- "JSKIT client bootstrap final route table."
608
- );
608
+ if (log.isDebugEnabled) {
609
+ log.debug(
610
+ {
611
+ routerRoutes: summarizeRouterRoutes(router),
612
+ currentPath: typeof window !== "undefined" ? String(window.location?.pathname || "") : ""
613
+ },
614
+ "JSKIT client bootstrap final route table."
615
+ );
616
+ }
609
617
  }
610
618
 
611
619
  return Object.freeze({
@@ -433,3 +433,38 @@ test("bootClientModules throws when packageMetadataClientProviders export is mis
433
433
  /packageMetadata provider export "MissingProvider" must be a capability provider/
434
434
  );
435
435
  });
436
+
437
+ test("bootClientModules builds route debug payloads only when the logger's debug is enabled", async () => {
438
+ const surfaceRuntime = createSurfaceRuntimeFixture();
439
+ const clientModules = [
440
+ {
441
+ packageId: "@example/auth",
442
+ packageMetadataUiRoutes: [{ id: "auth.login", path: "/auth/login", scope: "global", autoRegister: false }],
443
+ module: {
444
+ clientRoutes: [{ id: "auth.login", name: "auth-login", path: "/auth/login", scope: "global", component: {} }]
445
+ }
446
+ }
447
+ ];
448
+
449
+ for (const isDebugEnabled of [false, true]) {
450
+ const router = createRouterStub();
451
+ let routeTableReads = 0;
452
+ router.getRoutes = () => {
453
+ routeTableReads += 1;
454
+ return [...router.routes];
455
+ };
456
+ const messages = [];
457
+ await bootClientModules({
458
+ clientModules,
459
+ router,
460
+ surfaceRuntime,
461
+ surfaceMode: "all",
462
+ logger: { info() {}, warn() {}, error() {}, debug: (_payload, message) => messages.push(message), isDebugEnabled }
463
+ });
464
+
465
+ assert.equal(router.routes.length, 1);
466
+ assert.equal(routeTableReads > 0, isDebugEnabled);
467
+ assert.equal(messages.includes("Added client route to router."), isDebugEnabled);
468
+ assert.equal(messages.includes("JSKIT client bootstrap final route table."), isDebugEnabled);
469
+ }
470
+ });
@@ -56,6 +56,38 @@ function installAppPlugins(app, appPlugins = []) {
56
56
  }
57
57
  }
58
58
 
59
+ function isSurfaceRootRecord(record) {
60
+ return Boolean(record?.meta?.jskit?.surface);
61
+ }
62
+
63
+ // A page that hosts tabs or sub-views keeps its own record above the changing
64
+ // child, so the two locations share a non-leaf record below the surface root.
65
+ function staysOnSamePage(to, from) {
66
+ if (to.path === from.path) {
67
+ return true;
68
+ }
69
+ const fromParents = new Set((from.matched || []).slice(0, -1));
70
+ return (to.matched || []).slice(0, -1).some((record) => fromParents.has(record) && !isSurfaceRootRecord(record));
71
+ }
72
+
73
+ /**
74
+ * JSKIT's default router scroll behaviour: Back/Forward restore the saved
75
+ * position, a new screen opens at the top (or at its hash target), and tab,
76
+ * query or hash changes within the same page keep the current position.
77
+ */
78
+ function shellScrollBehavior(to, from, savedPosition) {
79
+ if (savedPosition) {
80
+ return savedPosition;
81
+ }
82
+ if (staysOnSamePage(to, from)) {
83
+ return false;
84
+ }
85
+ if (to.hash) {
86
+ return { el: to.hash, top: 0 };
87
+ }
88
+ return { top: 0 };
89
+ }
90
+
59
91
  function createSurfaceShellRouter({
60
92
  createRouter,
61
93
  history,
@@ -64,7 +96,8 @@ function createSurfaceShellRouter({
64
96
  surfaceMode,
65
97
  fallbackRoute = null,
66
98
  notFoundComponent = null,
67
- guard = false
99
+ guard = false,
100
+ scrollBehavior = shellScrollBehavior
68
101
  } = {}) {
69
102
  if (typeof createRouter !== "function") {
70
103
  throw new TypeError("createSurfaceShellRouter requires createRouter().");
@@ -79,7 +112,8 @@ function createSurfaceShellRouter({
79
112
  });
80
113
  const router = createRouter({
81
114
  history,
82
- routes: activeRoutes
115
+ routes: activeRoutes,
116
+ ...(typeof scrollBehavior === "function" ? { scrollBehavior } : {})
83
117
  });
84
118
 
85
119
  if (guard !== false) {
@@ -170,14 +204,7 @@ async function bootstrapClientShellApp({
170
204
  logger: bootstrapLogger
171
205
  });
172
206
 
173
- const isDebugEnabled =
174
- typeof resolvedLogger?.isDebugEnabled === "boolean"
175
- ? resolvedLogger.isDebugEnabled
176
- : resolveClientBootstrapDebugEnabled({
177
- env,
178
- debugEnabled,
179
- debugEnvKey
180
- });
207
+ const { isDebugEnabled } = bootstrapLogger;
181
208
  if (isDebugEnabled) {
182
209
  bootstrapLogger.info(
183
210
  {
@@ -245,6 +272,7 @@ async function bootstrapClientShellApp({
245
272
  }
246
273
 
247
274
  export {
275
+ shellScrollBehavior,
248
276
  resolveClientBootstrapDebugEnabled,
249
277
  createClientBootstrapLogger,
250
278
  createSurfaceShellRouter,
@@ -4,7 +4,8 @@ import { createSurfaceRuntime } from "../shared/surface/runtime.js";
4
4
  import {
5
5
  bootstrapClientShellApp,
6
6
  createClientBootstrapLogger,
7
- createSurfaceShellRouter
7
+ createSurfaceShellRouter,
8
+ shellScrollBehavior
8
9
  } from "./shellBootstrap.js";
9
10
  import { getClientAppConfig } from "./appConfig.js";
10
11
 
@@ -52,11 +53,6 @@ test("createSurfaceShellRouter builds active routes and installs a guard", () =>
52
53
  surfaceMode: "app",
53
54
  notFoundComponent: {},
54
55
  guard: {
55
- surfaceDefinitions: {
56
- app: { id: "app", pagesRoot: "app", requiresAuth: false },
57
- admin: { id: "admin", pagesRoot: "admin", requiresAuth: false }
58
- },
59
- defaultSurfaceId: "app",
60
56
  webRootAllowed: "yes"
61
57
  }
62
58
  });
@@ -68,6 +64,57 @@ test("createSurfaceShellRouter builds active routes and installs a guard", () =>
68
64
  assert.equal(activeRoutes.some((route) => route.path === "/admin/home"), false);
69
65
  assert.equal(activeRoutes.some((route) => route.path === "/:pathMatch(.*)*"), true);
70
66
  assert.equal(fallbackRoute.path, "/:pathMatch(.*)*");
67
+ assert.equal(routerState.options.scrollBehavior, shellScrollBehavior);
68
+ });
69
+
70
+ test("createSurfaceShellRouter lets an app replace or disable the scroll behaviour", () => {
71
+ const surfaceRuntime = createSurfaceRuntimeFixture();
72
+ const custom = () => ({ top: 42 });
73
+ const seen = [];
74
+ for (const scrollBehavior of [custom, false]) {
75
+ createSurfaceShellRouter({
76
+ createRouter(options) {
77
+ seen.push(options);
78
+ return {};
79
+ },
80
+ routes: [],
81
+ surfaceRuntime,
82
+ surfaceMode: "app",
83
+ notFoundComponent: {},
84
+ scrollBehavior
85
+ });
86
+ }
87
+ assert.equal(seen[0].scrollBehavior, custom);
88
+ assert.equal(Object.hasOwn(seen[1], "scrollBehavior"), false);
89
+ });
90
+
91
+ test("shellScrollBehavior opens new screens at the top and keeps in-page changes still", () => {
92
+ const surface = { path: "/home", meta: { jskit: { surface: "home" } } };
93
+ const settings = { path: "/home/settings", meta: {} };
94
+ const general = { path: "/home/settings/general", meta: {} };
95
+ const profile = { path: "/home/settings/profile", meta: {} };
96
+ const contacts = { path: "/home/contacts", meta: {} };
97
+ const contact = { path: "/home/contacts/:id", meta: {} };
98
+ const at = (path, matched, extra = {}) => ({ path, matched, hash: "", ...extra });
99
+
100
+ const saved = { left: 0, top: 640 };
101
+ assert.equal(shellScrollBehavior(at("/home/contacts", [surface, contacts]), at("/home", [surface]), saved), saved);
102
+ assert.deepEqual(shellScrollBehavior(at("/home/contacts", [surface, contacts]), at("/home", [surface]), null), { top: 0 });
103
+ // Another record of the same screen type is still a new screen.
104
+ assert.deepEqual(
105
+ shellScrollBehavior(at("/home/contacts/2", [surface, contact]), at("/home/contacts/1", [surface, contact]), null),
106
+ { top: 0 }
107
+ );
108
+ // Query, hash or tab changes within one page keep the position.
109
+ assert.equal(shellScrollBehavior(at("/home/contacts", [surface, contacts]), at("/home/contacts", [surface, contacts]), null), false);
110
+ assert.equal(
111
+ shellScrollBehavior(at("/home/settings/profile", [surface, settings, profile]), at("/home/settings/general", [surface, settings, general]), null),
112
+ false
113
+ );
114
+ assert.deepEqual(
115
+ shellScrollBehavior(at("/home/contacts", [surface, contacts], { hash: "#recent" }), at("/home", [surface]), null),
116
+ { el: "#recent", top: 0 }
117
+ );
71
118
  });
72
119
 
73
120
  test("createClientBootstrapLogger enables debug from env flag", () => {
@@ -80,6 +127,38 @@ test("createClientBootstrapLogger enables debug from env flag", () => {
80
127
  assert.equal(logger.isDebugEnabled, true);
81
128
  });
82
129
 
130
+ test("bootstrapClientShellApp hands hooks and modules one logger that carries its debug flag", async () => {
131
+ for (const debugEnabled of [false, true]) {
132
+ let routeTableReads = 0;
133
+ const seenLoggers = [];
134
+ const router = {
135
+ addRoute() {},
136
+ getRoutes() {
137
+ routeTableReads += 1;
138
+ return [];
139
+ },
140
+ async isReady() {}
141
+ };
142
+ const result = await bootstrapClientShellApp({
143
+ createApp: () => ({ use() {}, mount() {} }),
144
+ router,
145
+ surfaceRuntime: createSurfaceRuntimeFixture(),
146
+ logger: { info() {}, warn() {}, error() {} },
147
+ debugEnabled,
148
+ bootClientModules: async ({ logger }) => {
149
+ seenLoggers.push(logger);
150
+ return {};
151
+ },
152
+ onAfterRouterReady: ({ logger }) => seenLoggers.push(logger)
153
+ });
154
+
155
+ assert.equal(result.debugEnabled, debugEnabled);
156
+ assert.equal(result.logger.isDebugEnabled, debugEnabled);
157
+ assert.deepEqual(seenLoggers, [result.logger, result.logger]);
158
+ assert.equal(routeTableReads > 0, debugEnabled);
159
+ }
160
+ });
161
+
83
162
  test("bootstrapClientShellApp boots modules, reinstalls fallback route, and mounts app", async () => {
84
163
  const calls = [];
85
164
  const logs = [];
@@ -1,4 +1,3 @@
1
- import { deriveSurfaceRouteBaseFromPagesRoot } from "../shared/surface/index.js";
2
1
  import { filterRoutesBySurface } from "../shared/surface/runtime.js";
3
2
  import { isExternalLinkTarget } from "../shared/support/linkPath.js";
4
3
  import { AUTH_POLICY_AUTHENTICATED, AUTH_POLICY_PUBLIC } from "../shared/support/policies.js";
@@ -101,20 +100,6 @@ function resolveSearchFromFullPath(fullPath) {
101
100
  return hashStart < 0 ? rawFullPath.slice(queryStart) : rawFullPath.slice(queryStart, hashStart);
102
101
  }
103
102
 
104
- function resolveSurfaceDefinition(surfaceDefinitions, surfaceId) {
105
- const normalizedSurfaceId = String(surfaceId || "")
106
- .trim()
107
- .toLowerCase();
108
- if (!normalizedSurfaceId) {
109
- return null;
110
- }
111
- const definition = surfaceDefinitions[normalizedSurfaceId];
112
- if (!definition || typeof definition !== "object") {
113
- return null;
114
- }
115
- return definition;
116
- }
117
-
118
103
  function normalizeWebRootAllowed(value) {
119
104
  const normalizedValue = String(value || "")
120
105
  .trim()
@@ -125,27 +110,7 @@ function normalizeWebRootAllowed(value) {
125
110
  return WEB_ROOT_ALLOW_YES;
126
111
  }
127
112
 
128
- function resolveDefaultSurfaceRootPath({ surfaceDefinitions, defaultSurfaceId }) {
129
- const defaultSurface = resolveSurfaceDefinition(surfaceDefinitions, defaultSurfaceId);
130
- return String(
131
- defaultSurface?.routeBase || deriveSurfaceRouteBaseFromPagesRoot(defaultSurface?.pagesRoot || "")
132
- ).trim() || "/";
133
- }
134
-
135
- function resolveSurfaceRequiresAuth({ pathname, surfaceRuntime, surfaceDefinitions }) {
136
- const normalizedPathname = String(pathname || "/").trim() || "/";
137
- const surfaceId = surfaceRuntime.resolveSurfaceFromPathname(normalizedPathname);
138
- const surfaceDefinition = resolveSurfaceDefinition(surfaceDefinitions, surfaceId);
139
- return Boolean(surfaceDefinition?.requiresAuth);
140
- }
141
-
142
- function resolveEffectiveRouteGuard({
143
- to,
144
- surfaceRuntime,
145
- surfaceDefinitions,
146
- authenticatedPolicy = AUTH_POLICY_AUTHENTICATED,
147
- publicPolicy = AUTH_POLICY_PUBLIC
148
- }) {
113
+ function resolveEffectiveRouteGuard({ to, surfaceRuntime, authenticatedPolicy, publicPolicy }) {
149
114
  const routeGuard = resolveRouteGuard(to);
150
115
  const routePolicy = normalizeGuardPolicy(routeGuard?.policy);
151
116
  if (routePolicy) {
@@ -154,13 +119,8 @@ function resolveEffectiveRouteGuard({
154
119
  };
155
120
  }
156
121
 
157
- if (
158
- resolveSurfaceRequiresAuth({
159
- pathname: to?.path || "/",
160
- surfaceRuntime,
161
- surfaceDefinitions
162
- })
163
- ) {
122
+ const surfaceId = surfaceRuntime.resolveSurfaceFromPathname(String(to?.path || "/").trim() || "/");
123
+ if (surfaceRuntime.getSurfaceDefinition(surfaceId)?.requiresAuth) {
164
124
  return {
165
125
  policy: authenticatedPolicy
166
126
  };
@@ -245,31 +205,27 @@ function evaluateShellGuard({ guard, to, guardEvaluatorKey }) {
245
205
 
246
206
  function createShellBeforeEachGuard({
247
207
  surfaceRuntime,
248
- surfaceDefinitions,
249
- defaultSurfaceId,
250
208
  webRootAllowed = WEB_ROOT_ALLOW_YES,
251
209
  guardEvaluatorKey = DEFAULT_GUARD_EVALUATOR_KEY,
252
210
  authenticatedPolicy = AUTH_POLICY_AUTHENTICATED,
253
211
  publicPolicy = AUTH_POLICY_PUBLIC
254
212
  } = {}) {
255
- if (!surfaceRuntime || typeof surfaceRuntime.resolveSurfaceFromPathname !== "function") {
256
- throw new TypeError("createShellBeforeEachGuard requires surfaceRuntime.resolveSurfaceFromPathname().");
257
- }
258
- if (!surfaceDefinitions || typeof surfaceDefinitions !== "object" || Array.isArray(surfaceDefinitions)) {
259
- throw new TypeError("createShellBeforeEachGuard requires a surfaceDefinitions object.");
213
+ if (
214
+ !surfaceRuntime ||
215
+ typeof surfaceRuntime.resolveSurfaceFromPathname !== "function" ||
216
+ typeof surfaceRuntime.getSurfaceDefinition !== "function"
217
+ ) {
218
+ throw new TypeError(
219
+ "createShellBeforeEachGuard requires surfaceRuntime.resolveSurfaceFromPathname() and getSurfaceDefinition()."
220
+ );
260
221
  }
261
222
 
223
+ const defaultSurfaceRootPath =
224
+ surfaceRuntime.getSurfaceDefinition(surfaceRuntime.DEFAULT_SURFACE_ID)?.routeBase || "/";
225
+ const redirectWebRoot = normalizeWebRootAllowed(webRootAllowed) === WEB_ROOT_ALLOW_NO && defaultSurfaceRootPath !== "/";
226
+
262
227
  return (to) => {
263
- const normalizedWebRootAllowed = normalizeWebRootAllowed(webRootAllowed);
264
- const defaultSurfaceRootPath = resolveDefaultSurfaceRootPath({
265
- surfaceDefinitions,
266
- defaultSurfaceId
267
- });
268
- if (
269
- normalizedWebRootAllowed === WEB_ROOT_ALLOW_NO &&
270
- String(to?.path || "/").trim() === "/" &&
271
- defaultSurfaceRootPath !== "/"
272
- ) {
228
+ if (redirectWebRoot && String(to?.path || "/").trim() === "/") {
273
229
  const search = resolveSearchFromFullPath(to?.fullPath || "");
274
230
  const hash = String(to?.hash || "").trim();
275
231
  return `${defaultSurfaceRootPath}${search}${hash}`;
@@ -278,7 +234,6 @@ function createShellBeforeEachGuard({
278
234
  const guard = resolveEffectiveRouteGuard({
279
235
  to,
280
236
  surfaceRuntime,
281
- surfaceDefinitions,
282
237
  authenticatedPolicy,
283
238
  publicPolicy
284
239
  });