@jskit-ai/shell-web 0.1.53 → 0.1.55

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.53",
4
+ version: "0.1.55",
5
5
  kind: "runtime",
6
6
  description: "Web shell layout runtime with outlet-based placement contributions.",
7
7
  dependsOn: [],
@@ -39,12 +39,17 @@ export default Object.freeze({
39
39
  {
40
40
  subpath: "./client/error",
41
41
  summary: "Exports default error policy and runtime error reporter hook."
42
+ },
43
+ {
44
+ subpath: "./client/bootstrap",
45
+ summary: "Exports the shared client bootstrap handler registry used to extend /api/bootstrap handling."
42
46
  }
43
47
  ],
44
48
  containerTokens: {
45
49
  server: [],
46
50
  client: [
47
51
  "runtime.web-placement.client",
52
+ "runtime.web-bootstrap.client",
48
53
  "runtime.web-error.client",
49
54
  "runtime.web-error.presentation-store.client",
50
55
  "shell.web.query-client"
@@ -117,7 +122,7 @@ export default Object.freeze({
117
122
  runtime: {
118
123
  "@mdi/js": "^7.4.47",
119
124
  "@tanstack/vue-query": "^5.90.5",
120
- "@jskit-ai/kernel": "0.1.54",
125
+ "@jskit-ai/kernel": "0.1.56",
121
126
  "vuetify": "^4.0.0"
122
127
  },
123
128
  dev: {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/shell-web",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
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/bootstrap": "./src/client/bootstrap/index.js",
12
13
  "./server/support/localLinkItemScaffolds": "./src/server/support/localLinkItemScaffolds.js",
13
14
  "./client/navigation/linkResolver": "./src/client/navigation/linkResolver.js",
14
15
  "./client/components/ShellLayout": "./src/client/components/ShellLayout.vue",
@@ -24,8 +25,12 @@
24
25
  "dependencies": {
25
26
  "@mdi/js": "^7.4.47",
26
27
  "@tanstack/vue-query": "^5.90.5",
27
- "@jskit-ai/kernel": "0.1.54",
28
+ "@jskit-ai/kernel": "0.1.56",
28
29
  "pinia": "^3.0.4",
29
30
  "vuetify": "^4.0.0"
31
+ },
32
+ "peerDependencies": {
33
+ "vue": "^3.5.13",
34
+ "vue-router": "^5.0.4"
30
35
  }
31
36
  }
@@ -0,0 +1,6 @@
1
+ function resolveBootstrapErrorStatusCode(error) {
2
+ const statusCode = Number(error?.statusCode || error?.status || 0);
3
+ return Number.isInteger(statusCode) && statusCode > 0 ? statusCode : 0;
4
+ }
5
+
6
+ export { resolveBootstrapErrorStatusCode };
@@ -0,0 +1,68 @@
1
+ const BOOTSTRAP_PAYLOAD_HANDLER_TAG = "runtime.web-bootstrap.handlers.client";
2
+
3
+ function assertTaggableApp(app, context = "bootstrap payload handler registry") {
4
+ if (!app || typeof app.singleton !== "function" || typeof app.tag !== "function") {
5
+ throw new Error(`${context} requires application singleton()/tag().`);
6
+ }
7
+ }
8
+
9
+ function registerBootstrapPayloadHandler(app, token, factory) {
10
+ assertTaggableApp(app, "registerBootstrapPayloadHandler");
11
+ app.singleton(token, factory);
12
+ app.tag(token, BOOTSTRAP_PAYLOAD_HANDLER_TAG);
13
+ }
14
+
15
+ function normalizeBootstrapPayloadHandler(entry) {
16
+ if (typeof entry === "function") {
17
+ return Object.freeze({
18
+ handlerId: String(entry.name || "anonymous"),
19
+ order: 0,
20
+ applyBootstrapPayload: entry
21
+ });
22
+ }
23
+
24
+ if (!entry || typeof entry !== "object" || typeof entry.applyBootstrapPayload !== "function") {
25
+ return null;
26
+ }
27
+
28
+ return Object.freeze({
29
+ ...entry,
30
+ handlerId: String(entry.handlerId || "anonymous"),
31
+ order: Number.isFinite(entry.order) ? Number(entry.order) : 0
32
+ });
33
+ }
34
+
35
+ function resolveBootstrapPayloadHandlers(scope) {
36
+ if (!scope || typeof scope.resolveTag !== "function") {
37
+ return [];
38
+ }
39
+
40
+ const rawEntries = scope.resolveTag(BOOTSTRAP_PAYLOAD_HANDLER_TAG);
41
+ const queue = Array.isArray(rawEntries) ? [...rawEntries] : [rawEntries];
42
+ const entries = [];
43
+
44
+ while (queue.length > 0) {
45
+ const entry = queue.shift();
46
+ if (Array.isArray(entry)) {
47
+ queue.push(...entry);
48
+ continue;
49
+ }
50
+ const normalized = normalizeBootstrapPayloadHandler(entry);
51
+ if (normalized) {
52
+ entries.push(normalized);
53
+ }
54
+ }
55
+
56
+ return entries.sort((left, right) => {
57
+ if (left.order !== right.order) {
58
+ return left.order - right.order;
59
+ }
60
+ return left.handlerId.localeCompare(right.handlerId);
61
+ });
62
+ }
63
+
64
+ export {
65
+ BOOTSTRAP_PAYLOAD_HANDLER_TAG,
66
+ registerBootstrapPayloadHandler,
67
+ resolveBootstrapPayloadHandlers
68
+ };
@@ -0,0 +1,6 @@
1
+ export {
2
+ BOOTSTRAP_PAYLOAD_HANDLER_TAG,
3
+ registerBootstrapPayloadHandler,
4
+ resolveBootstrapPayloadHandlers
5
+ } from "./bootstrapPayloadHandlerRegistry.js";
6
+ export { resolveBootstrapErrorStatusCode } from "./bootstrapErrorStatus.js";
@@ -64,9 +64,9 @@ const { drawerOpen, toggleDrawer, resolvedSurface, resolvedSurfaceLabel } = useS
64
64
  </v-navigation-drawer>
65
65
 
66
66
  <v-main class="bg-background">
67
- <v-container fluid class="pa-4">
68
- <h1 class="text-h5 mb-2">{{ title }}</h1>
69
- <p class="text-body-2 text-medium-emphasis mb-4">{{ subtitle }}</p>
67
+ <v-container fluid class="shell-layout__content">
68
+ <h1 v-if="title" class="shell-layout__title text-h5">{{ title }}</h1>
69
+ <p v-if="subtitle" class="shell-layout__subtitle text-body-2 text-medium-emphasis">{{ subtitle }}</p>
70
70
  <slot />
71
71
  </v-container>
72
72
  </v-main>
@@ -77,4 +77,16 @@ const { drawerOpen, toggleDrawer, resolvedSurface, resolvedSurfaceLabel } = useS
77
77
  .shell-layout {
78
78
  min-height: 72vh;
79
79
  }
80
+
81
+ .shell-layout__content {
82
+ padding: 0.75rem 1rem 1rem;
83
+ }
84
+
85
+ .shell-layout__title {
86
+ margin-bottom: 0.25rem;
87
+ }
88
+
89
+ .shell-layout__subtitle {
90
+ margin-bottom: 0.75rem;
91
+ }
80
92
  </style>
@@ -16,6 +16,11 @@ export { default as ShellTabLinkItem } from "./components/ShellTabLinkItem.vue";
16
16
  export { useShellLayoutState } from "./composables/useShellLayoutState.js";
17
17
  export { useShellLayoutStore } from "./stores/useShellLayoutStore.js";
18
18
  export { useShellErrorPresentationStore } from "./stores/useShellErrorPresentationStore.js";
19
+ export {
20
+ BOOTSTRAP_PAYLOAD_HANDLER_TAG,
21
+ registerBootstrapPayloadHandler,
22
+ resolveBootstrapPayloadHandlers
23
+ } from "./bootstrap/index.js";
19
24
 
20
25
  const clientProviders = Object.freeze([ShellWebClientProvider]);
21
26
 
@@ -24,6 +24,9 @@ import {
24
24
  import { createWebPlacementRuntime } from "../placement/runtime.js";
25
25
  import { useShellErrorPresentationStore } from "../stores/useShellErrorPresentationStore.js";
26
26
  import { buildSurfaceConfigContext } from "../placement/surfaceContext.js";
27
+ import { createShellBootstrapRuntime } from "../runtime/bootstrapRuntime.js";
28
+ import { registerBootstrapPayloadHandler } from "../bootstrap/bootstrapPayloadHandlerRegistry.js";
29
+ import { resolveBootstrapErrorStatusCode } from "../bootstrap/bootstrapErrorStatus.js";
27
30
 
28
31
  // Keep this constant for diagnostics, but keep import() below as a literal string so Vite can statically analyze it.
29
32
  const APP_PLACEMENT_MODULE_SPECIFIER = "/src/placement.js";
@@ -229,12 +232,49 @@ class ShellWebClientProvider {
229
232
  static id = "shell.web.client";
230
233
 
231
234
  register(app) {
232
- if (!app || typeof app.singleton !== "function") {
233
- throw new Error("ShellWebClientProvider requires application singleton().");
235
+ if (!app || typeof app.singleton !== "function" || typeof app.tag !== "function") {
236
+ throw new Error("ShellWebClientProvider requires application singleton()/tag().");
234
237
  }
235
238
 
236
239
  const logger = createSharedProviderLogger(isRecord(app) ? app : null);
240
+ registerBootstrapPayloadHandler(app, "shell.web.bootstrap.surfaceAccessHandler", () =>
241
+ Object.freeze({
242
+ handlerId: "shell.web.bootstrap.surfaceAccess",
243
+ order: 0,
244
+ applyBootstrapPayload({ payload = {}, placementRuntime, source } = {}) {
245
+ placementRuntime.setContext(
246
+ {
247
+ surfaceAccess:
248
+ payload?.surfaceAccess && typeof payload.surfaceAccess === "object" ? payload.surfaceAccess : {}
249
+ },
250
+ {
251
+ source
252
+ }
253
+ );
254
+ },
255
+ handleBootstrapError({ error, placementRuntime, source } = {}) {
256
+ if (resolveBootstrapErrorStatusCode(error) !== 401) {
257
+ return;
258
+ }
259
+
260
+ placementRuntime.setContext(
261
+ {
262
+ surfaceAccess: {}
263
+ },
264
+ {
265
+ source
266
+ }
267
+ );
268
+ }
269
+ })
270
+ );
237
271
  app.singleton("runtime.web-placement.client", () => createWebPlacementRuntime({ app, logger }));
272
+ app.singleton("runtime.web-bootstrap.client", (scope) =>
273
+ createShellBootstrapRuntime({
274
+ app: scope,
275
+ logger
276
+ })
277
+ );
238
278
  app.singleton("shell.web.query-client", () => createShellWebQueryClient());
239
279
  app.singleton("runtime.web-error.presentation-store.client", () => createErrorPresentationStore());
240
280
  app.singleton("runtime.web-error.client", (scope) =>
@@ -285,6 +325,11 @@ class ShellWebClientProvider {
285
325
  const errorConfig = await loadAppErrorConfig(logger, errorRuntime);
286
326
  applyAppErrorConfig(errorRuntime, errorConfig);
287
327
 
328
+ const bootstrapRuntime = app.make("runtime.web-bootstrap.client");
329
+ if (bootstrapRuntime && typeof bootstrapRuntime.initialize === "function") {
330
+ await bootstrapRuntime.initialize();
331
+ }
332
+
288
333
  if (!app.has("jskit.client.vue.app")) {
289
334
  return;
290
335
  }
@@ -0,0 +1,195 @@
1
+ import { createProviderLogger as createSharedProviderLogger } from "@jskit-ai/kernel/shared/support/providerLogger";
2
+ import { resolveBootstrapPayloadHandlers } from "../bootstrap/bootstrapPayloadHandlerRegistry.js";
3
+
4
+ const DEFAULT_BOOTSTRAP_PATH = "/api/bootstrap";
5
+
6
+ function normalizeObject(value) {
7
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
8
+ }
9
+
10
+ function buildBootstrapUrl({ path = DEFAULT_BOOTSTRAP_PATH, query = {} } = {}) {
11
+ const normalizedPath = String(path || DEFAULT_BOOTSTRAP_PATH).trim() || DEFAULT_BOOTSTRAP_PATH;
12
+ const params = new URLSearchParams();
13
+
14
+ for (const [key, value] of Object.entries(normalizeObject(query))) {
15
+ const normalizedKey = String(key || "").trim();
16
+ const normalizedValue = String(value || "").trim();
17
+ if (!normalizedKey || !normalizedValue) {
18
+ continue;
19
+ }
20
+ params.set(normalizedKey, normalizedValue);
21
+ }
22
+
23
+ const queryString = params.toString();
24
+ return queryString ? `${normalizedPath}?${queryString}` : normalizedPath;
25
+ }
26
+
27
+ function normalizeBootstrapResponseError(response, url) {
28
+ const error = new Error(`Bootstrap payload request failed with status ${response.status}.`);
29
+ error.statusCode = Number(response.status || 0);
30
+ error.url = url;
31
+ return error;
32
+ }
33
+
34
+ function createShellBootstrapRuntime({
35
+ app,
36
+ logger = null,
37
+ fetchImplementation = globalThis.fetch,
38
+ bootstrapPath = DEFAULT_BOOTSTRAP_PATH
39
+ } = {}) {
40
+ if (!app || typeof app.has !== "function" || typeof app.make !== "function" || typeof app.resolveTag !== "function") {
41
+ throw new Error("createShellBootstrapRuntime requires application has()/make()/resolveTag().");
42
+ }
43
+ if (!app.has("runtime.web-placement.client")) {
44
+ throw new Error("createShellBootstrapRuntime requires shell-web placement runtime.");
45
+ }
46
+
47
+ const runtimeLogger = logger || createSharedProviderLogger(app);
48
+ const placementRuntime = app.make("runtime.web-placement.client");
49
+ const router = app.has("jskit.client.router") ? app.make("jskit.client.router") : null;
50
+ let initialized = false;
51
+ let refreshQueue = Promise.resolve();
52
+
53
+ async function resolveBootstrapRequest(reason = "manual") {
54
+ const handlers = resolveBootstrapPayloadHandlers(app);
55
+ let request = {
56
+ path: bootstrapPath,
57
+ query: {},
58
+ meta: {}
59
+ };
60
+
61
+ for (const handler of handlers) {
62
+ if (typeof handler.resolveBootstrapRequest !== "function") {
63
+ continue;
64
+ }
65
+
66
+ const contribution = normalizeObject(
67
+ await handler.resolveBootstrapRequest({
68
+ app,
69
+ router,
70
+ placementRuntime,
71
+ reason,
72
+ request: Object.freeze({
73
+ path: request.path,
74
+ query: Object.freeze({ ...request.query }),
75
+ meta: Object.freeze({ ...request.meta })
76
+ })
77
+ })
78
+ );
79
+
80
+ request = {
81
+ path: String(contribution.path || request.path || bootstrapPath).trim() || bootstrapPath,
82
+ query: {
83
+ ...request.query,
84
+ ...normalizeObject(contribution.query)
85
+ },
86
+ meta: {
87
+ ...request.meta,
88
+ ...normalizeObject(contribution.meta)
89
+ }
90
+ };
91
+ }
92
+
93
+ return Object.freeze({
94
+ path: request.path,
95
+ query: Object.freeze({ ...request.query }),
96
+ meta: Object.freeze({ ...request.meta })
97
+ });
98
+ }
99
+
100
+ async function applyBootstrapPayload(payload, reason = "manual", request = Object.freeze({})) {
101
+ const handlers = resolveBootstrapPayloadHandlers(app);
102
+ const source = `shell-web.bootstrap.${String(reason || "manual").trim() || "manual"}`;
103
+
104
+ for (const handler of handlers) {
105
+ await handler.applyBootstrapPayload({
106
+ app,
107
+ router,
108
+ placementRuntime,
109
+ payload,
110
+ request,
111
+ reason,
112
+ source
113
+ });
114
+ }
115
+
116
+ return payload;
117
+ }
118
+
119
+ async function applyBootstrapError(error, reason = "manual", request = Object.freeze({})) {
120
+ const handlers = resolveBootstrapPayloadHandlers(app);
121
+ const source = `shell-web.bootstrap.${String(reason || "manual").trim() || "manual"}`;
122
+
123
+ for (const handler of handlers) {
124
+ if (typeof handler.handleBootstrapError !== "function") {
125
+ continue;
126
+ }
127
+
128
+ await handler.handleBootstrapError({
129
+ app,
130
+ router,
131
+ placementRuntime,
132
+ error,
133
+ request,
134
+ reason,
135
+ source
136
+ });
137
+ }
138
+ }
139
+
140
+ async function performRefresh(reason = "manual") {
141
+ if (typeof fetchImplementation !== "function") {
142
+ throw new Error("Bootstrap payload fetch requires a fetch implementation.");
143
+ }
144
+
145
+ const request = await resolveBootstrapRequest(reason);
146
+ const url = buildBootstrapUrl(request);
147
+
148
+ try {
149
+ const response = await fetchImplementation(url, {
150
+ method: "GET",
151
+ credentials: "include",
152
+ headers: {
153
+ accept: "application/json"
154
+ }
155
+ });
156
+
157
+ if (!response.ok) {
158
+ throw normalizeBootstrapResponseError(response, url);
159
+ }
160
+
161
+ const payload = await response.json();
162
+ return applyBootstrapPayload(payload, reason, request);
163
+ } catch (error) {
164
+ await applyBootstrapError(error, reason, request);
165
+ runtimeLogger.warn(
166
+ {
167
+ reason,
168
+ error: String(error?.message || error || "unknown error")
169
+ },
170
+ "shell-web bootstrap refresh failed."
171
+ );
172
+ return null;
173
+ }
174
+ }
175
+
176
+ function refresh(reason = "manual") {
177
+ refreshQueue = refreshQueue.then(() => performRefresh(reason));
178
+ return refreshQueue;
179
+ }
180
+
181
+ async function initialize() {
182
+ if (initialized) {
183
+ return null;
184
+ }
185
+ initialized = true;
186
+ return refresh("init");
187
+ }
188
+
189
+ return Object.freeze({
190
+ initialize,
191
+ refresh
192
+ });
193
+ }
194
+
195
+ export { createShellBootstrapRuntime };
@@ -64,9 +64,9 @@ const { drawerOpen, toggleDrawer, resolvedSurface, resolvedSurfaceLabel } = useS
64
64
  </v-navigation-drawer>
65
65
 
66
66
  <v-main class="bg-background">
67
- <v-container fluid class="pa-4">
68
- <h1 class="text-h5 mb-2">{{ title }}</h1>
69
- <p class="text-body-2 text-medium-emphasis mb-4">{{ subtitle }}</p>
67
+ <v-container fluid class="shell-layout__content">
68
+ <h1 v-if="title" class="shell-layout__title text-h5">{{ title }}</h1>
69
+ <p v-if="subtitle" class="shell-layout__subtitle text-body-2 text-medium-emphasis">{{ subtitle }}</p>
70
70
  <slot />
71
71
  </v-container>
72
72
  </v-main>
@@ -77,4 +77,16 @@ const { drawerOpen, toggleDrawer, resolvedSurface, resolvedSurfaceLabel } = useS
77
77
  .shell-layout {
78
78
  min-height: 72vh;
79
79
  }
80
+
81
+ .shell-layout__content {
82
+ padding: 0.75rem 1rem 1rem;
83
+ }
84
+
85
+ .shell-layout__title {
86
+ margin-bottom: 0.25rem;
87
+ }
88
+
89
+ .shell-layout__subtitle {
90
+ margin-bottom: 0.75rem;
91
+ }
80
92
  </style>
@@ -29,7 +29,7 @@ const health = computed(() => {
29
29
 
30
30
  <template>
31
31
  <v-card rounded="lg" elevation="1" border>
32
- <v-card-item>
32
+ <v-card-item class="home-surface-card__header">
33
33
  <template #prepend>
34
34
  <v-chip color="primary" size="small" label>Home</v-chip>
35
35
  </template>
@@ -37,7 +37,7 @@ const health = computed(() => {
37
37
  <v-card-subtitle>Main public surface</v-card-subtitle>
38
38
  </v-card-item>
39
39
  <v-divider />
40
- <v-card-text class="d-flex flex-column ga-4">
40
+ <v-card-text class="home-surface-card__body d-flex flex-column ga-3">
41
41
  <div class="d-flex flex-wrap ga-3">
42
42
  <v-chip color="secondary" variant="tonal" label>Route: /home</v-chip>
43
43
  <v-chip color="info" variant="tonal" label>Health: {{ health }}</v-chip>
@@ -49,3 +49,13 @@ const health = computed(() => {
49
49
  </v-card-text>
50
50
  </v-card>
51
51
  </template>
52
+
53
+ <style scoped>
54
+ .home-surface-card__header {
55
+ padding: 0.875rem 1rem;
56
+ }
57
+
58
+ .home-surface-card__body {
59
+ padding: 0.875rem 1rem 1rem;
60
+ }
61
+ </style>
@@ -50,7 +50,6 @@ addPlacement({
50
50
  label: "General",
51
51
  surface: "home",
52
52
  scopedSuffix: "/settings/general",
53
- unscopedSuffix: "/settings/general",
54
- to: "./general"
53
+ unscopedSuffix: "/settings/general"
55
54
  }
56
55
  });
@@ -0,0 +1,187 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { registerBootstrapPayloadHandler } from "../src/client/bootstrap/bootstrapPayloadHandlerRegistry.js";
4
+ import { createShellBootstrapRuntime } from "../src/client/runtime/bootstrapRuntime.js";
5
+
6
+ function createPlacementRuntime(initialContext = {}) {
7
+ let context = Object.freeze({ ...initialContext });
8
+ const listeners = new Set();
9
+
10
+ return {
11
+ getContext() {
12
+ return context;
13
+ },
14
+ setContext(value = {}, { replace = false } = {}) {
15
+ const next = value && typeof value === "object" ? { ...value } : {};
16
+ context = Object.freeze(replace ? next : { ...context, ...next });
17
+ for (const listener of listeners) {
18
+ listener({
19
+ type: "context.updated"
20
+ });
21
+ }
22
+ return context;
23
+ },
24
+ subscribe(listener) {
25
+ if (typeof listener === "function") {
26
+ listeners.add(listener);
27
+ }
28
+ return () => {
29
+ listeners.delete(listener);
30
+ };
31
+ }
32
+ };
33
+ }
34
+
35
+ function createAppDouble({ placementRuntime, realtimeSocket = null } = {}) {
36
+ const singletons = new Map();
37
+ const singletonInstances = new Map();
38
+ return {
39
+ singleton(token, factory) {
40
+ singletons.set(token, factory);
41
+ },
42
+ tag(token, tagName) {
43
+ const current = this._tags.get(tagName) || [];
44
+ current.push(token);
45
+ this._tags.set(tagName, current);
46
+ },
47
+ resolveTag(tagName) {
48
+ return (this._tags.get(tagName) || []).map((token) => this.make(token));
49
+ },
50
+ _tags: new Map(),
51
+ has(token) {
52
+ if (token === "runtime.web-placement.client") {
53
+ return true;
54
+ }
55
+ if (token === "runtime.realtime.client.socket") {
56
+ return Boolean(realtimeSocket);
57
+ }
58
+ return singletons.has(token) || singletonInstances.has(token);
59
+ },
60
+ make(token) {
61
+ if (token === "runtime.web-placement.client") {
62
+ return placementRuntime;
63
+ }
64
+ if (token === "runtime.realtime.client.socket") {
65
+ return realtimeSocket;
66
+ }
67
+ if (singletonInstances.has(token)) {
68
+ return singletonInstances.get(token);
69
+ }
70
+ const factory = singletons.get(token);
71
+ if (!factory) {
72
+ throw new Error(`Unknown token ${String(token)}`);
73
+ }
74
+ const instance = factory(this);
75
+ singletonInstances.set(token, instance);
76
+ return instance;
77
+ }
78
+ };
79
+ }
80
+
81
+ test("shell bootstrap runtime refreshes /api/bootstrap on init and applies registered handlers", async () => {
82
+ const placementRuntime = createPlacementRuntime({
83
+ auth: {}
84
+ });
85
+ const payloads = [
86
+ {
87
+ surfaceAccess: {
88
+ consoleowner: false
89
+ }
90
+ },
91
+ {
92
+ surfaceAccess: {
93
+ consoleowner: true
94
+ }
95
+ }
96
+ ];
97
+ const calls = [];
98
+ const observedRequests = [];
99
+ const observedResolveMeta = [];
100
+ const app = createAppDouble({ placementRuntime });
101
+ registerBootstrapPayloadHandler(app, "test.bootstrap.request", () =>
102
+ Object.freeze({
103
+ handlerId: "test.bootstrap.request",
104
+ order: -10,
105
+ resolveBootstrapRequest() {
106
+ return {
107
+ query: {
108
+ workspaceSlug: "acme"
109
+ },
110
+ meta: {
111
+ path: "/w/acme/dashboard"
112
+ }
113
+ };
114
+ },
115
+ applyBootstrapPayload({ request }) {
116
+ observedRequests.push(request);
117
+ }
118
+ })
119
+ );
120
+ registerBootstrapPayloadHandler(app, "test.bootstrap.request-meta", () =>
121
+ Object.freeze({
122
+ handlerId: "test.bootstrap.request-meta",
123
+ order: -5,
124
+ resolveBootstrapRequest({ request }) {
125
+ observedResolveMeta.push(request?.meta?.path || "");
126
+ return {};
127
+ },
128
+ applyBootstrapPayload() {}
129
+ })
130
+ );
131
+ registerBootstrapPayloadHandler(app, "test.bootstrap.surfaceAccess", () =>
132
+ Object.freeze({
133
+ handlerId: "test.bootstrap.surfaceAccess",
134
+ order: 0,
135
+ applyBootstrapPayload({ payload, placementRuntime: targetRuntime }) {
136
+ targetRuntime.setContext({
137
+ surfaceAccess: payload.surfaceAccess || {}
138
+ });
139
+ }
140
+ })
141
+ );
142
+
143
+ const runtime = createShellBootstrapRuntime({
144
+ app,
145
+ fetchImplementation: async (url) => {
146
+ calls.push(String(url || ""));
147
+ const payload = payloads.shift() || {};
148
+ return {
149
+ ok: true,
150
+ async json() {
151
+ return payload;
152
+ }
153
+ };
154
+ }
155
+ });
156
+
157
+ await runtime.initialize();
158
+ assert.deepEqual(placementRuntime.getContext().surfaceAccess, {
159
+ consoleowner: false
160
+ });
161
+ await runtime.refresh("manual");
162
+ assert.deepEqual(calls, ["/api/bootstrap?workspaceSlug=acme", "/api/bootstrap?workspaceSlug=acme"]);
163
+ assert.deepEqual(observedResolveMeta, ["/w/acme/dashboard", "/w/acme/dashboard"]);
164
+ assert.deepEqual(observedRequests, [
165
+ {
166
+ path: "/api/bootstrap",
167
+ query: {
168
+ workspaceSlug: "acme"
169
+ },
170
+ meta: {
171
+ path: "/w/acme/dashboard"
172
+ }
173
+ },
174
+ {
175
+ path: "/api/bootstrap",
176
+ query: {
177
+ workspaceSlug: "acme"
178
+ },
179
+ meta: {
180
+ path: "/w/acme/dashboard"
181
+ }
182
+ }
183
+ ]);
184
+ assert.deepEqual(placementRuntime.getContext().surfaceAccess, {
185
+ consoleowner: true
186
+ });
187
+ });
@@ -41,15 +41,15 @@ test("placement registry accepts explicit non-global surface ids", () => {
41
41
  assert.equal(added, true);
42
42
  });
43
43
 
44
- test("placement registry rejects legacy split target fields", () => {
44
+ test("placement registry rejects split target fields", () => {
45
45
  const registry = createPlacementRegistry();
46
46
 
47
47
  assert.throws(
48
48
  () => registry.addPlacement({
49
- id: "example.legacy",
49
+ id: "example.split",
50
50
  host: "shell-layout",
51
51
  position: "top-right",
52
- componentToken: "example.legacy.component"
52
+ componentToken: "example.split.component"
53
53
  }),
54
54
  /must use "target" only/
55
55
  );
@@ -42,9 +42,15 @@ function createAppDouble({ surfaceRuntime = null } = {}) {
42
42
  plugins,
43
43
  pinia,
44
44
  vueApp,
45
+ _tags: new Map(),
45
46
  singleton(token, factory) {
46
47
  singletons.set(token, factory);
47
48
  },
49
+ tag(token, tagName) {
50
+ const current = this._tags.get(tagName) || [];
51
+ current.push(token);
52
+ this._tags.set(tagName, current);
53
+ },
48
54
  has(token) {
49
55
  if (token === "jskit.client.vue.app") {
50
56
  return true;
@@ -78,51 +84,88 @@ function createAppDouble({ surfaceRuntime = null } = {}) {
78
84
  singletonInstances.set(token, instance);
79
85
  return instance;
80
86
  },
81
- resolveTag() {
82
- return [];
87
+ resolveTag(tagName) {
88
+ return (this._tags.get(tagName) || []).map((token) => this.make(token));
83
89
  }
84
90
  };
85
91
  }
86
92
 
93
+ async function withFetchStub(responsePayload, callback) {
94
+ const previousFetch = globalThis.fetch;
95
+ globalThis.fetch = async () => ({
96
+ ok: true,
97
+ async json() {
98
+ return responsePayload;
99
+ }
100
+ });
101
+
102
+ try {
103
+ return await callback();
104
+ } finally {
105
+ if (previousFetch === undefined) {
106
+ delete globalThis.fetch;
107
+ } else {
108
+ globalThis.fetch = previousFetch;
109
+ }
110
+ }
111
+ }
112
+
113
+ async function withFetchImplementation(fetchImplementation, callback) {
114
+ const previousFetch = globalThis.fetch;
115
+ globalThis.fetch = fetchImplementation;
116
+
117
+ try {
118
+ return await callback();
119
+ } finally {
120
+ if (previousFetch === undefined) {
121
+ delete globalThis.fetch;
122
+ } else {
123
+ globalThis.fetch = previousFetch;
124
+ }
125
+ }
126
+ }
127
+
87
128
  test("shell web client provider binds runtime and injects it into Vue app", async () => {
88
- const app = createAppDouble();
89
- const provider = new ShellWebClientProvider();
90
-
91
- provider.register(app);
92
- assert.equal(app.singletons.has("runtime.web-placement.client"), true);
93
- assert.equal(app.singletons.has("runtime.web-error.client"), true);
94
- assert.equal(app.singletons.has("runtime.web-error.presentation-store.client"), true);
95
-
96
- await provider.boot(app);
97
- assert.equal(app.plugins.length, 1);
98
- assert.equal(typeof app.plugins[0].plugin.install, "function");
99
- assert.equal(typeof app.plugins[0].options?.queryClient, "object");
100
-
101
- const providedByKey = new Map(app.provided.map((entry) => [entry.key, entry.value]));
102
-
103
- assert.equal(providedByKey.has("jskit.shell-web.runtime.web-placement.client"), true);
104
- assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.client"), true);
105
- assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.presentation-store.client"), true);
106
-
107
- const placementRuntime = providedByKey.get("jskit.shell-web.runtime.web-placement.client");
108
- assert.equal(typeof placementRuntime.getPlacements, "function");
109
- assert.equal(typeof placementRuntime.getContext, "function");
110
- assert.equal(typeof placementRuntime.setContext, "function");
111
- assert.equal(typeof placementRuntime.getContext().surfaceConfig, "object");
112
-
113
- const errorRuntime = providedByKey.get("jskit.shell-web.runtime.web-error.client");
114
- assert.equal(typeof errorRuntime.report, "function");
115
- assert.equal(typeof errorRuntime.configure, "function");
116
-
117
- const errorStore = providedByKey.get("jskit.shell-web.runtime.web-error.presentation-store.client");
118
- assert.equal(typeof errorStore.getState, "function");
119
- assert.equal(typeof errorStore.present, "function");
120
-
121
- const errorPresentationStore = useShellErrorPresentationStore(app.pinia);
122
- assert.equal(errorPresentationStore.revision, 0);
123
- assert.equal(typeof errorPresentationStore.present, "function");
124
- errorStore.present("banner", { message: "Hello" });
125
- assert.equal(errorPresentationStore.channels.banner[0].message, "Hello");
129
+ await withFetchStub({ surfaceAccess: {} }, async () => {
130
+ const app = createAppDouble();
131
+ const provider = new ShellWebClientProvider();
132
+
133
+ provider.register(app);
134
+ assert.equal(app.singletons.has("runtime.web-placement.client"), true);
135
+ assert.equal(app.singletons.has("runtime.web-error.client"), true);
136
+ assert.equal(app.singletons.has("runtime.web-error.presentation-store.client"), true);
137
+
138
+ await provider.boot(app);
139
+ assert.equal(app.plugins.length, 1);
140
+ assert.equal(typeof app.plugins[0].plugin.install, "function");
141
+ assert.equal(typeof app.plugins[0].options?.queryClient, "object");
142
+
143
+ const providedByKey = new Map(app.provided.map((entry) => [entry.key, entry.value]));
144
+
145
+ assert.equal(providedByKey.has("jskit.shell-web.runtime.web-placement.client"), true);
146
+ assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.client"), true);
147
+ assert.equal(providedByKey.has("jskit.shell-web.runtime.web-error.presentation-store.client"), true);
148
+
149
+ const placementRuntime = providedByKey.get("jskit.shell-web.runtime.web-placement.client");
150
+ assert.equal(typeof placementRuntime.getPlacements, "function");
151
+ assert.equal(typeof placementRuntime.getContext, "function");
152
+ assert.equal(typeof placementRuntime.setContext, "function");
153
+ assert.equal(typeof placementRuntime.getContext().surfaceConfig, "object");
154
+
155
+ const errorRuntime = providedByKey.get("jskit.shell-web.runtime.web-error.client");
156
+ assert.equal(typeof errorRuntime.report, "function");
157
+ assert.equal(typeof errorRuntime.configure, "function");
158
+
159
+ const errorStore = providedByKey.get("jskit.shell-web.runtime.web-error.presentation-store.client");
160
+ assert.equal(typeof errorStore.getState, "function");
161
+ assert.equal(typeof errorStore.present, "function");
162
+
163
+ const errorPresentationStore = useShellErrorPresentationStore(app.pinia);
164
+ assert.equal(errorPresentationStore.revision, 0);
165
+ assert.equal(typeof errorPresentationStore.present, "function");
166
+ errorStore.present("banner", { message: "Hello" });
167
+ assert.equal(errorPresentationStore.channels.banner[0].message, "Hello");
168
+ });
126
169
  });
127
170
 
128
171
  test("shell web client provider resolves surface config from client app config", async () => {
@@ -134,35 +177,63 @@ test("shell web client provider resolves surface config from client app config",
134
177
  });
135
178
 
136
179
  try {
137
- const app = createAppDouble({
138
- surfaceRuntime: {
139
- DEFAULT_SURFACE_ID: "app",
140
- listEnabledSurfaceIds() {
141
- return ["app", "admin", "console"];
142
- },
143
- listSurfaceDefinitions() {
144
- return [
145
- { id: "app", pagesRoot: "w/[workspaceSlug]", requiresWorkspace: true, enabled: true },
146
- { id: "admin", pagesRoot: "w/[workspaceSlug]/admin", requiresWorkspace: true, enabled: true },
147
- { id: "console", pagesRoot: "console", requiresWorkspace: false, enabled: true }
148
- ];
180
+ await withFetchStub({ surfaceAccess: {} }, async () => {
181
+ const app = createAppDouble({
182
+ surfaceRuntime: {
183
+ DEFAULT_SURFACE_ID: "app",
184
+ listEnabledSurfaceIds() {
185
+ return ["app", "admin", "console"];
186
+ },
187
+ listSurfaceDefinitions() {
188
+ return [
189
+ { id: "app", pagesRoot: "w/[workspaceSlug]", requiresWorkspace: true, enabled: true },
190
+ { id: "admin", pagesRoot: "w/[workspaceSlug]/admin", requiresWorkspace: true, enabled: true },
191
+ { id: "console", pagesRoot: "console", requiresWorkspace: false, enabled: true }
192
+ ];
193
+ }
149
194
  }
150
- }
195
+ });
196
+ const provider = new ShellWebClientProvider();
197
+ provider.register(app);
198
+
199
+ await provider.boot(app);
200
+
201
+ const placementRuntime = app.make("runtime.web-placement.client");
202
+ const context = placementRuntime.getContext();
203
+ assert.equal(context.surfaceConfig.tenancyMode, "workspaces");
204
+ assert.equal(context.surfaceConfig.defaultSurfaceId, "app");
205
+ assert.deepEqual(context.surfaceConfig.enabledSurfaceIds, ["app", "admin", "console"]);
206
+ assert.deepEqual(context.surfaceAccessPolicies, {
207
+ public: {}
208
+ });
151
209
  });
210
+ } finally {
211
+ setClientAppConfig({});
212
+ }
213
+ });
214
+
215
+ test("shell web client provider clears generic surface access on bootstrap 401", async () => {
216
+ await withFetchImplementation(async () => ({
217
+ ok: false,
218
+ status: 401
219
+ }), async () => {
220
+ const app = createAppDouble();
152
221
  const provider = new ShellWebClientProvider();
153
222
  provider.register(app);
154
223
 
155
- await provider.boot(app);
156
-
157
224
  const placementRuntime = app.make("runtime.web-placement.client");
158
- const context = placementRuntime.getContext();
159
- assert.equal(context.surfaceConfig.tenancyMode, "workspaces");
160
- assert.equal(context.surfaceConfig.defaultSurfaceId, "app");
161
- assert.deepEqual(context.surfaceConfig.enabledSurfaceIds, ["app", "admin", "console"]);
162
- assert.deepEqual(context.surfaceAccessPolicies, {
163
- public: {}
164
- });
165
- } finally {
166
- setClientAppConfig({});
167
- }
225
+ placementRuntime.setContext(
226
+ {
227
+ surfaceAccess: {
228
+ consoleowner: true
229
+ }
230
+ },
231
+ {
232
+ source: "test.seed"
233
+ }
234
+ );
235
+
236
+ await provider.boot(app);
237
+ assert.deepEqual(placementRuntime.getContext().surfaceAccess, {});
238
+ });
168
239
  });
@@ -75,7 +75,7 @@ test("shell-web placement template seeds default Home and Settings drawer naviga
75
75
  assert.match(source, /target: "home-settings:primary-menu"/);
76
76
  assert.match(source, /label: "General"/);
77
77
  assert.match(source, /unscopedSuffix: "\/settings\/general"/);
78
- assert.match(source, /to: "\.\/general"/);
78
+ assert.doesNotMatch(source, /to: "\.\/general"/);
79
79
  });
80
80
 
81
81
  test("shell-web descriptor metadata advertises home settings outlets, default drawer links, and installs the scaffold page", () => {