every-plugin 3.0.0-rc.0 → 3.0.0-rc.11

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 (127) hide show
  1. package/dist/build/rspack/chunk-completeness-plugin.cjs +19 -7
  2. package/dist/build/rspack/chunk-completeness-plugin.cjs.map +1 -1
  3. package/dist/build/rspack/chunk-completeness-plugin.mjs +19 -7
  4. package/dist/build/rspack/chunk-completeness-plugin.mjs.map +1 -1
  5. package/dist/build/ui/generated-config.cjs +4 -0
  6. package/dist/build/ui/generated-config.cjs.map +1 -1
  7. package/dist/build/ui/generated-config.d.cts.map +1 -1
  8. package/dist/build/ui/generated-config.d.mts.map +1 -1
  9. package/dist/build/ui/generated-config.mjs +4 -1
  10. package/dist/build/ui/generated-config.mjs.map +1 -1
  11. package/dist/build/ui/rsbuild-config.cjs +1 -0
  12. package/dist/build/ui/rsbuild-config.cjs.map +1 -1
  13. package/dist/build/ui/rsbuild-config.mjs +1 -0
  14. package/dist/build/ui/rsbuild-config.mjs.map +1 -1
  15. package/dist/client.cjs +69 -0
  16. package/dist/client.cjs.map +1 -0
  17. package/dist/client.d.cts +15 -0
  18. package/dist/client.d.cts.map +1 -0
  19. package/dist/client.d.mts +15 -0
  20. package/dist/client.d.mts.map +1 -0
  21. package/dist/client.mjs +68 -0
  22. package/dist/client.mjs.map +1 -0
  23. package/dist/dev/host.cjs +149 -0
  24. package/dist/dev/host.cjs.map +1 -0
  25. package/dist/dev/host.d.cts +32 -0
  26. package/dist/dev/host.d.cts.map +1 -0
  27. package/dist/dev/host.d.mts +32 -0
  28. package/dist/dev/host.d.mts.map +1 -0
  29. package/dist/dev/host.mjs +148 -0
  30. package/dist/dev/host.mjs.map +1 -0
  31. package/dist/dev/serve.cjs +16 -111
  32. package/dist/dev/serve.cjs.map +1 -1
  33. package/dist/dev/serve.d.cts.map +1 -1
  34. package/dist/dev/serve.d.mts.map +1 -1
  35. package/dist/dev/serve.mjs +17 -112
  36. package/dist/dev/serve.mjs.map +1 -1
  37. package/dist/errors.cjs +1 -0
  38. package/dist/errors.cjs.map +1 -1
  39. package/dist/errors.d.cts +2 -2
  40. package/dist/errors.d.mts +2 -2
  41. package/dist/errors.mjs +2 -2
  42. package/dist/errors.mjs.map +1 -1
  43. package/dist/index.cjs +3 -0
  44. package/dist/index.d.cts +5 -4
  45. package/dist/index.d.mts +5 -4
  46. package/dist/index.mjs +3 -2
  47. package/dist/plugin.cjs +1 -0
  48. package/dist/plugin.cjs.map +1 -1
  49. package/dist/plugin.d.cts +23 -3
  50. package/dist/plugin.d.cts.map +1 -1
  51. package/dist/plugin.d.mts +22 -2
  52. package/dist/plugin.d.mts.map +1 -1
  53. package/dist/plugin.mjs +1 -0
  54. package/dist/plugin.mjs.map +1 -1
  55. package/dist/remote-entry.cjs +0 -1
  56. package/dist/remote-entry.cjs.map +1 -1
  57. package/dist/remote-entry.d.cts +1 -2
  58. package/dist/remote-entry.d.cts.map +1 -1
  59. package/dist/remote-entry.d.mts +1 -2
  60. package/dist/remote-entry.d.mts.map +1 -1
  61. package/dist/remote-entry.mjs +1 -1
  62. package/dist/remote-entry.mjs.map +1 -1
  63. package/dist/runtime/errors.cjs +20 -0
  64. package/dist/runtime/errors.cjs.map +1 -1
  65. package/dist/runtime/errors.d.cts +23 -1
  66. package/dist/runtime/errors.d.cts.map +1 -1
  67. package/dist/runtime/errors.d.mts +23 -1
  68. package/dist/runtime/errors.d.mts.map +1 -1
  69. package/dist/runtime/errors.mjs +20 -1
  70. package/dist/runtime/errors.mjs.map +1 -1
  71. package/dist/runtime/mf-config.cjs +1 -1
  72. package/dist/runtime/mf-config.mjs +1 -1
  73. package/dist/runtime/services/module-federation.service.cjs +1 -7
  74. package/dist/runtime/services/module-federation.service.cjs.map +1 -1
  75. package/dist/runtime/services/module-federation.service.mjs +1 -7
  76. package/dist/runtime/services/module-federation.service.mjs.map +1 -1
  77. package/dist/runtime/services/plugin-loader.service.cjs +29 -1
  78. package/dist/runtime/services/plugin-loader.service.cjs.map +1 -1
  79. package/dist/runtime/services/plugin-loader.service.d.cts +2 -2
  80. package/dist/runtime/services/plugin-loader.service.mjs +29 -1
  81. package/dist/runtime/services/plugin-loader.service.mjs.map +1 -1
  82. package/dist/runtime/services/shared-identity.cjs +1 -1
  83. package/dist/runtime/services/shared-identity.cjs.map +1 -1
  84. package/dist/runtime/services/shared-identity.mjs +1 -1
  85. package/dist/runtime/services/shared-identity.mjs.map +1 -1
  86. package/dist/types.d.cts +27 -2
  87. package/dist/types.d.cts.map +1 -1
  88. package/dist/types.d.mts +26 -1
  89. package/dist/types.d.mts.map +1 -1
  90. package/dist/ui/manifest/construct.cjs +69 -18
  91. package/dist/ui/manifest/construct.cjs.map +1 -1
  92. package/dist/ui/manifest/construct.d.cts.map +1 -1
  93. package/dist/ui/manifest/construct.d.mts.map +1 -1
  94. package/dist/ui/manifest/construct.mjs +69 -18
  95. package/dist/ui/manifest/construct.mjs.map +1 -1
  96. package/dist/ui/manifest/generator.cjs +1 -1
  97. package/dist/ui/manifest/generator.mjs +1 -1
  98. package/dist/ui/manifest/index.cjs +1 -1
  99. package/dist/ui/manifest/index.mjs +1 -1
  100. package/dist/version-manifest.cjs +14 -2
  101. package/dist/version-manifest.cjs.map +1 -1
  102. package/dist/version-manifest.d.cts +11 -1
  103. package/dist/version-manifest.d.cts.map +1 -1
  104. package/dist/version-manifest.d.mts +11 -1
  105. package/dist/version-manifest.d.mts.map +1 -1
  106. package/dist/version-manifest.mjs +14 -2
  107. package/dist/version-manifest.mjs.map +1 -1
  108. package/package.json +20 -3
  109. package/skills/plugin-client/SKILL.md +10 -4
  110. package/skills/plugin-testing/SKILL.md +2 -1
  111. package/src/build/rspack/chunk-completeness-plugin.ts +15 -6
  112. package/src/build/ui/generated-config.ts +4 -0
  113. package/src/build/ui/rsbuild-config.ts +1 -0
  114. package/src/client.ts +111 -0
  115. package/src/dev/host.ts +235 -0
  116. package/src/dev/serve.ts +22 -140
  117. package/src/errors.ts +2 -1
  118. package/src/index.ts +9 -0
  119. package/src/plugin.ts +26 -1
  120. package/src/remote-entry.ts +1 -2
  121. package/src/runtime/errors.ts +44 -0
  122. package/src/runtime/services/module-federation.service.ts +4 -7
  123. package/src/runtime/services/plugin-loader.service.ts +46 -2
  124. package/src/runtime/services/shared-identity.ts +1 -1
  125. package/src/types.ts +40 -2
  126. package/src/ui/manifest/construct.ts +68 -15
  127. package/src/version-manifest.ts +19 -1
package/src/plugin.ts CHANGED
@@ -5,6 +5,7 @@ import type { ContractedRouter, Implementer } from "@orpc/server";
5
5
  import { implement } from "@orpc/server";
6
6
  import type { Scope } from "effect";
7
7
  import { Context, Effect, Layer } from "effect";
8
+ import type { PluginWarningInfo } from "./runtime/errors";
8
9
  import { type PluginRuntimeError, toPluginRuntimeError } from "./runtime/errors";
9
10
 
10
11
  type ContextOutput<T> = T extends AnySchema ? InferSchemaOutput<T> : Record<string, never>;
@@ -19,11 +20,26 @@ export type PluginConfigFor<
19
20
  context: TRequestContext;
20
21
  };
21
22
 
22
- type PluginInitializeInput<V extends AnySchema, S extends AnySchema> = {
23
+ export type PluginInitializeInput<V extends AnySchema, S extends AnySchema> = {
24
+ /**
25
+ * The framework-assigned plugin id — the same key the host uses for secret
26
+ * injection and per-plugin database namespacing. Arrives as a value so
27
+ * plugin code never needs the ambient `PluginIdTag`.
28
+ */
29
+ pluginId: string;
23
30
  variables: InferSchemaOutput<V>;
24
31
  secrets: InferSchemaOutput<S>;
25
32
  };
26
33
 
34
+ /**
35
+ * A plugin's own config-derived warnings, evaluated once with the validated
36
+ * config after initialization. Declaring `warnings` opts the plugin out of
37
+ * the loader's default missing-secret warning — these messages replace it.
38
+ */
39
+ export type PluginWarningsFn<V extends AnySchema, S extends AnySchema> = (
40
+ config: PluginInitializeInput<V, S>,
41
+ ) => PluginWarningInfo[] | string[];
42
+
27
43
  export class PluginIdTag extends Context.Service<PluginIdTag, string>()("PluginId") {}
28
44
 
29
45
  /**
@@ -50,6 +66,13 @@ type PluginDefinition<
50
66
  * module statically.
51
67
  */
52
68
  servicesTag?: Context.Key<any, any>;
69
+ /**
70
+ * Optional warnings function evaluated once with the validated config after
71
+ * initialization. Returns structured non-fatal warnings (e.g. an optional
72
+ * secret was not set, so the feature depending on it is disabled).
73
+ * Declaring it opts out of the loader's default missing-secret warning.
74
+ */
75
+ warnings?: PluginWarningsFn<V, S>;
53
76
  /**
54
77
  * Initialize the plugin by returning an Effect `Layer`.
55
78
  *
@@ -107,6 +130,7 @@ export interface Plugin<
107
130
  readonly contract: TContract;
108
131
  readonly configSchema: PluginConfigFor<TVariables, TSecrets, TRequestContext>;
109
132
  readonly servicesTag?: Context.Key<any, any>;
133
+ readonly warnings?: PluginWarningsFn<TVariables, TSecrets>;
110
134
 
111
135
  initialize(
112
136
  config: PluginInitializeInput<TVariables, TSecrets>,
@@ -156,6 +180,7 @@ export const createPlugin: CreatePluginFn = function createPlugin<
156
180
  readonly contract = config.contract;
157
181
  readonly configSchema = configSchema;
158
182
  readonly servicesTag = config.servicesTag;
183
+ readonly warnings = config.warnings;
159
184
 
160
185
  initialize(
161
186
  pluginConfig: PluginInitializeInput<V, S>,
@@ -23,8 +23,7 @@ const DEFAULT_TIMEOUT_MS = 120_000;
23
23
  const RETRY_BASE_DELAY_MS = 500;
24
24
  const RETRY_MAX_DELAY_MS = 5_000;
25
25
 
26
- export const sleep = (ms: number): Promise<void> =>
27
- new Promise((resolve) => setTimeout(resolve, ms));
26
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
28
27
 
29
28
  /**
30
29
  * Deletes the failed remote's keys from the process-global entry-load cache.
@@ -282,6 +282,50 @@ export interface PluginLoadFailureInfo {
282
282
  dbUrlMasked?: string;
283
283
  }
284
284
 
285
+ /**
286
+ * Structured, serializable warning surfaced alongside plugin failures in
287
+ * health endpoints and dashboards. Warnings are non-fatal: the plugin loaded
288
+ * and initialized successfully, but runs with reduced functionality (e.g. an
289
+ * optional secret was not set, so the feature depending on it is disabled).
290
+ */
291
+ export interface PluginWarningInfo {
292
+ /** loader-side key (host entry key, dev plugin id) */
293
+ pluginKey: string;
294
+ message: string;
295
+ hint?: string;
296
+ }
297
+
298
+ /**
299
+ * The loader's default warning policy for declared-but-unset secrets: a
300
+ * successfully initialized plugin whose declared secret names resolved to
301
+ * nothing (missing or empty in the environment) gets one consolidated
302
+ * warning. `*_DATABASE_URL` keys are excluded — they have the shared-API-DB
303
+ * fallback, and a genuinely-missing DB URL fails initialization (a `failure`,
304
+ * not a warning). A plugin that declares its own `warnings` function opts out
305
+ * of this default.
306
+ */
307
+ export const defaultMissingSecretWarnings = (
308
+ pluginKey: string,
309
+ declaredKeys: readonly string[],
310
+ resolvedKeys: readonly string[],
311
+ ): PluginWarningInfo[] => {
312
+ const resolved = new Set(resolvedKeys);
313
+ const missing = declaredKeys.filter(
314
+ (key) => !resolved.has(key) && !key.endsWith("_DATABASE_URL"),
315
+ );
316
+ if (missing.length === 0) return [];
317
+ return [
318
+ {
319
+ pluginKey,
320
+ message:
321
+ missing.length === 1
322
+ ? `Declared secret ${missing[0]} is not set — features depending on it are disabled`
323
+ : `${missing.length} declared secrets are not set: ${missing.join(", ")} — features depending on them are disabled`,
324
+ hint: "Set the secret(s) in your environment (.env in development); see the plugin's config schema for what each enables",
325
+ },
326
+ ];
327
+ };
328
+
285
329
  const NETWORK_RETRYABLE_PATTERNS = ["ETIMEDOUT", "ECONNRESET", "timeout", "503", "429"] as const;
286
330
  const NETWORK_DEAD_PATTERNS = ["ECONNREFUSED", "ENOTFOUND", "EHOSTUNREACH"] as const;
287
331
  const RETRYABLE_ORPC_CODES = new Set([
@@ -111,13 +111,10 @@ const resolveRemoteSelfName = (pluginId: string, url: string) =>
111
111
  const memo = remoteSelfNames.get(memoKey);
112
112
  if (memo) return memo;
113
113
 
114
- let selfName: string | null = null;
115
- try {
116
- const manifest = yield* Effect.promise(() => fetchRemoteIdentityManifest(url));
117
- selfName = manifest?.metaData?.name?.trim() || null;
118
- } catch {
119
- selfName = null;
120
- }
114
+ const manifest = yield* Effect.tryPromise(() => fetchRemoteIdentityManifest(url)).pipe(
115
+ Effect.orElseSucceed(() => null),
116
+ );
117
+ const selfName = manifest?.metaData?.name?.trim() || null;
121
118
 
122
119
  const resolved = selfName ?? getNormalizedRemoteName(pluginId);
123
120
  remoteSelfNames.set(memoKey, resolved);
@@ -11,11 +11,39 @@ import type {
11
11
  PluginMetadata,
12
12
  PluginRegistry,
13
13
  } from "../../types";
14
+ import type { PluginWarningInfo } from "../errors";
14
15
  import { PluginRuntimeError, toPluginRuntimeError } from "../errors";
15
16
  import { validate } from "../validation";
16
17
  import { ModuleFederationService } from "./module-federation.service";
17
18
  import { SecretsService } from "./secrets.service";
18
19
 
20
+ /**
21
+ * Evaluate a plugin's `warnings` function against the validated config.
22
+ * Fail-safe: a throwing warnings function becomes a warning itself rather
23
+ * than failing initialization. Strings are normalized to `PluginWarningInfo`
24
+ * with the plugin's registry key.
25
+ */
26
+ const collectPluginWarnings = (
27
+ plugin: AnyPlugin,
28
+ config: { pluginId: string; variables: unknown; secrets: unknown },
29
+ ): PluginWarningInfo[] => {
30
+ const warningsFn = plugin.warnings;
31
+ if (!warningsFn) return [];
32
+ try {
33
+ const raw = warningsFn(config as never);
34
+ return raw.map((warning) =>
35
+ typeof warning === "string" ? { pluginKey: plugin.id, message: warning } : warning,
36
+ );
37
+ } catch (error) {
38
+ return [
39
+ {
40
+ pluginKey: plugin.id,
41
+ message: `Plugin warnings check failed: ${error instanceof Error ? error.message : String(error)}`,
42
+ },
43
+ ];
44
+ }
45
+ };
46
+
19
47
  export class PluginRegistryTag extends Context.Service<PluginRegistryTag, PluginRegistry>()(
20
48
  "PluginRegistry",
21
49
  ) {}
@@ -271,7 +299,14 @@ export const PluginLoaderServiceDefault = Layer.effect(
271
299
  // initialization leaks until the process exits.
272
300
  const effectContext = yield* Effect.gen(function* () {
273
301
  const layer = yield* plugin
274
- .initialize({ variables: _variables, secrets: hydratedConfig.secrets }, plugins ?? {})
302
+ .initialize(
303
+ {
304
+ pluginId: plugin.id,
305
+ variables: _variables,
306
+ secrets: hydratedConfig.secrets,
307
+ },
308
+ plugins ?? {},
309
+ )
275
310
  .pipe(
276
311
  Effect.provideService(PluginIdTag, plugin.id),
277
312
  Effect.provideService(Scope.Scope, scope),
@@ -310,9 +345,18 @@ export const PluginLoaderServiceDefault = Layer.effect(
310
345
  return {
311
346
  plugin,
312
347
  metadata: pluginInstance.metadata,
313
- config: { variables: _variables, secrets: hydratedConfig.secrets },
348
+ config: {
349
+ pluginId: plugin.id,
350
+ variables: _variables,
351
+ secrets: hydratedConfig.secrets,
352
+ },
314
353
  effectContext,
315
354
  scope,
355
+ warnings: collectPluginWarnings(plugin, {
356
+ pluginId: plugin.id,
357
+ variables: _variables,
358
+ secrets: hydratedConfig.secrets,
359
+ }),
316
360
  } satisfies InitializedPlugin<T>;
317
361
  }),
318
362
  };
@@ -19,7 +19,7 @@ function manifestUrl(remoteUrl: string): string {
19
19
 
20
20
  function normalizeBaseUrl(remoteUrl: string): string {
21
21
  const withoutHash = remoteUrl.split("#")[0] ?? remoteUrl;
22
- return withoutHash.replace(/\/(mf-manifest\.json|remoteEntry\.js)$/i, "");
22
+ return withoutHash.replace(/\/(mf-manifest\.json|remoteEntry(?:\.[^/]+)?\.js)$/i, "");
23
23
  }
24
24
 
25
25
  export function compareSharedIdentity(
package/src/types.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  import type { ContractedRouter, RouterClient } from "@orpc/server";
8
8
  import type { Context, Scope } from "effect";
9
9
  import type { Plugin } from "./plugin";
10
+ import type { PluginWarningInfo } from "./runtime/errors";
10
11
 
11
12
  /**
12
13
  * Registry bindings interface - populated via module augmentation
@@ -23,6 +24,24 @@ import type { Plugin } from "./plugin";
23
24
  // biome-ignore lint/suspicious/noEmptyInterface: required for module augmentation pattern
24
25
  export interface RegisteredPlugins {}
25
26
 
27
+ export type ModuleTransport = "inproc" | "rpc" | "mf";
28
+
29
+ export type ModuleSurface = "api" | "ui" | "jobs";
30
+
31
+ export interface ModuleEndpoint {
32
+ readonly transport: ModuleTransport;
33
+ readonly surface?: ModuleSurface;
34
+ readonly url: string;
35
+ readonly path?: `/${string}`;
36
+ readonly manifest?: string;
37
+ }
38
+
39
+ export type PluginTransport = ModuleTransport;
40
+
41
+ export type PluginSurface = ModuleSurface;
42
+
43
+ export type PluginEndpoint = ModuleEndpoint;
44
+
26
45
  /**
27
46
  * Base type for any plugin instance.
28
47
  */
@@ -45,8 +64,14 @@ export type AnyPluginConstructor = {
45
64
  * Registry entry that supports both direct module imports and remote URLs
46
65
  */
47
66
  export type PluginRegistryEntry =
48
- | { module: AnyPluginConstructor; remote?: string; version?: string; description?: string }
49
- | { remote: string; version?: string; description?: string };
67
+ | {
68
+ module: AnyPluginConstructor;
69
+ remote?: string;
70
+ endpoint?: PluginEndpoint;
71
+ version?: string;
72
+ description?: string;
73
+ }
74
+ | { remote: string; endpoint?: PluginEndpoint; version?: string; description?: string };
50
75
 
51
76
  /**
52
77
  * Extract plugin constructor type from registry entry
@@ -115,8 +140,18 @@ export type PluginClientType<T> = RouterClient<PluginRouterType<T>>;
115
140
  export type PluginServicesEntry<T = any> = {
116
141
  client: (context?: PluginContextInput<T>) => PluginClientType<T>;
117
142
  router: PluginRouterType<T>;
143
+ endpoint?: PluginEndpoint;
118
144
  };
119
145
 
146
+ export type ModuleClientEntry<C extends AnyContractRouter = AnyContractRouter> = {
147
+ client: (context?: Record<string, unknown>) => RouterClient<ContractedRouter<C, any>>;
148
+ router?: ContractedRouter<C, any>;
149
+ endpoint?: PluginEndpoint;
150
+ };
151
+
152
+ export type PluginClientEntry<C extends AnyContractRouter = AnyContractRouter> =
153
+ ModuleClientEntry<C>;
154
+
120
155
  /**
121
156
  * Extract plugin type from registered plugins by key
122
157
  * @param K - The plugin key
@@ -194,6 +229,7 @@ export interface InitializedPlugin<T extends AnyPlugin = AnyPlugin> {
194
229
  readonly plugin: T;
195
230
  readonly metadata: PluginMetadata;
196
231
  readonly config: {
232
+ pluginId: string;
197
233
  variables: InferSchemaOutput<T["configSchema"]["variables"]>;
198
234
  secrets: InferSchemaOutput<T["configSchema"]["secrets"]>;
199
235
  };
@@ -204,6 +240,8 @@ export interface InitializedPlugin<T extends AnyPlugin = AnyPlugin> {
204
240
  */
205
241
  readonly effectContext: Context.Context<any>;
206
242
  readonly scope: Scope.Closeable;
243
+ /** Non-fatal config warnings collected during initialization (empty when none). */
244
+ readonly warnings: PluginWarningInfo[];
207
245
  }
208
246
 
209
247
  /**
@@ -1,5 +1,6 @@
1
1
  import type { AnyRoute } from "@tanstack/react-router";
2
2
  import { createRootRoute, createRoute, Outlet, redirect } from "@tanstack/react-router";
3
+ import { CORE_UI_PLUGIN_KEY } from "./contract";
3
4
  import { digestOf } from "./descriptor";
4
5
  import type { PluginManifest, RouteRecord } from "./manifest-schema";
5
6
  import { MOUNT_REGISTRY, type MountDef, type MountId, resolveMountSegment } from "./mount-registry";
@@ -177,23 +178,53 @@ export async function constructTree(input: ConstructInput): Promise<ConstructedT
177
178
  resolved.push(await input.resolve(ref));
178
179
  }
179
180
 
181
+ const ordered = [
182
+ ...resolved.filter((plugin) => plugin.key === CORE_UI_PLUGIN_KEY),
183
+ ...resolved.filter((plugin) => plugin.key !== CORE_UI_PLUGIN_KEY),
184
+ ];
185
+
180
186
  const optionsByPlugin = new Map<string, Map<string, RouteOptionsBundle>>();
187
+ const skippedByPlugin = new Map<string, Set<string>>();
181
188
  for (const plugin of resolved) {
189
+ // A plugin with no manifest at all contributes no routes — skip it rather
190
+ // than crashing SSR composition (same contract as the missing-loader skip
191
+ // below).
192
+ if (!plugin.manifest?.routes) continue;
182
193
  const optionsById = new Map<string, RouteOptionsBundle>();
194
+ const skipped = new Set<string>();
183
195
  await Promise.all(
184
196
  plugin.manifest.routes.map(async (record) => {
185
197
  const loader = plugin.routeConfig.routeConfigLoaders[record.id];
186
198
  if (!loader) {
187
- throw new Error(`routeConfig for "${plugin.key}" has no loader for route "${record.id}"`);
199
+ console.log(
200
+ `[Compose] skipping route "${record.id}" in "${plugin.key}" — no routeConfig loader`,
201
+ );
202
+ skipped.add(record.id);
203
+ return;
188
204
  }
189
205
  optionsById.set(record.id, await loader());
190
206
  }),
191
207
  );
208
+ // cascade: any route whose parent is skipped is also skipped
209
+ let changed = true;
210
+ while (changed) {
211
+ changed = false;
212
+ for (const record of plugin.manifest.routes) {
213
+ if (skipped.has(record.id)) continue;
214
+ if (record.parentId && skipped.has(record.parentId)) {
215
+ skipped.add(record.id);
216
+ changed = true;
217
+ }
218
+ }
219
+ }
192
220
  optionsByPlugin.set(plugin.key, optionsById);
221
+ skippedByPlugin.set(plugin.key, skipped);
193
222
  }
194
223
 
195
224
  const declarations = new Map<MountId, MountDeclaration>();
196
- for (const plugin of resolved) {
225
+ const mountByLayoutId = new Map<string, MountId>();
226
+ for (const plugin of ordered) {
227
+ if (!plugin.manifest?.routes) continue;
197
228
  for (const record of plugin.manifest.routes) {
198
229
  if (!record.mount) continue;
199
230
  const mountId = record.mount as MountId;
@@ -205,15 +236,24 @@ export async function constructTree(input: ConstructInput): Promise<ConstructedT
205
236
  }
206
237
  const existing = declarations.get(mountId);
207
238
  if (existing) {
239
+ if (existing.key === CORE_UI_PLUGIN_KEY && plugin.key !== CORE_UI_PLUGIN_KEY) {
240
+ console.log(
241
+ `[Compose] mount declaration "${existing.key}:${existing.record.id}" (${mountId}) shadows "${plugin.key}:${record.id}" — core ui wins; shadowed declaration skipped`,
242
+ );
243
+ mountByLayoutId.set(record.id, mountId);
244
+ continue;
245
+ }
208
246
  throw new Error(
209
247
  `mount "${mountId}" declared by multiple sources: "${existing.key}" (${existing.record.file}) and "${plugin.key}" (${record.file})`,
210
248
  );
211
249
  }
250
+ if (skippedByPlugin.get(plugin.key)!.has(record.id)) continue;
212
251
  declarations.set(mountId, {
213
252
  key: plugin.key,
214
253
  record,
215
254
  options: optionsByPlugin.get(plugin.key)!.get(record.id)!,
216
255
  });
256
+ mountByLayoutId.set(record.id, mountId);
217
257
  }
218
258
  }
219
259
 
@@ -244,11 +284,6 @@ export async function constructTree(input: ConstructInput): Promise<ConstructedT
244
284
  mountRoutes.set(mountId, toAnyRoute(route));
245
285
  }
246
286
 
247
- const mountByLayoutId = new Map<string, MountId>();
248
- for (const [mountId, declaration] of declarations) {
249
- mountByLayoutId.set(declaration.record.id, mountId);
250
- }
251
-
252
287
  const nav: NavItem[] = [];
253
288
  const mountCounts: Record<string, number> = {};
254
289
  const childrenByParent = new Map<AnyRoute, AnyRoute[]>();
@@ -260,7 +295,8 @@ export async function constructTree(input: ConstructInput): Promise<ConstructedT
260
295
  childrenByParent.set(parent, list);
261
296
  };
262
297
 
263
- for (const plugin of resolved) {
298
+ for (const plugin of ordered) {
299
+ if (!plugin.manifest?.routes) continue;
264
300
  const optionsById = optionsByPlugin.get(plugin.key)!;
265
301
  const fullPathById = new Map<string, string>();
266
302
  const mountById = new Map<string, MountId>();
@@ -301,18 +337,25 @@ export async function constructTree(input: ConstructInput): Promise<ConstructedT
301
337
  throw new Error(`route "${record.id}" in "${plugin.key}" must live under a mount`);
302
338
  };
303
339
 
304
- const pending = plugin.manifest.routes.filter((record) => !record.mount);
340
+ const pending = plugin.manifest.routes.filter(
341
+ (record) => !record.mount && !skippedByPlugin.get(plugin.key)!.has(record.id),
342
+ );
343
+ const shadowedIds = new Set<string>();
305
344
  let progressed = true;
306
345
  while (pending.length > 0 && progressed) {
307
346
  progressed = false;
308
347
  for (let i = 0; i < pending.length; i += 1) {
309
348
  const record = pending[i]!;
310
- if (
311
- record.parentId &&
312
- !byId.has(record.parentId) &&
313
- !mountByLayoutId.has(record.parentId)
314
- ) {
315
- continue;
349
+ const parentId = record.parentId;
350
+ if (parentId) {
351
+ if (shadowedIds.has(parentId)) {
352
+ shadowedIds.add(record.id);
353
+ pending.splice(i, 1);
354
+ i -= 1;
355
+ progressed = true;
356
+ continue;
357
+ }
358
+ if (!byId.has(parentId) && !mountByLayoutId.has(parentId)) continue;
316
359
  }
317
360
  pending.splice(i, 1);
318
361
  progressed = true;
@@ -325,6 +368,16 @@ export async function constructTree(input: ConstructInput): Promise<ConstructedT
325
368
  const siblings = claimedPathsByParent.get(parent.route) ?? new Map<string, string>();
326
369
  const claimant = siblings.get(routePath);
327
370
  if (claimant) {
371
+ if (
372
+ plugin.key !== CORE_UI_PLUGIN_KEY &&
373
+ claimant.startsWith(`${CORE_UI_PLUGIN_KEY}:`)
374
+ ) {
375
+ console.log(
376
+ `[Compose] route claim "${claimant}" (${routePath}) shadows "${plugin.key}:${record.id}" — core ui wins; shadowed route skipped`,
377
+ );
378
+ shadowedIds.add(record.id);
379
+ break;
380
+ }
328
381
  throw new Error(
329
382
  `path collision: "${plugin.key}" route "${record.id}" declares "${routePath}" which is already claimed by "${claimant}" under the same parent`,
330
383
  );
@@ -41,9 +41,26 @@ export const WorkspaceVersionManifestSchema = z.object({
41
41
  * ignore it (zod strips unknown keys). Participates in the version hash.
42
42
  */
43
43
  files: z.record(z.string(), SRI).optional(),
44
+ /**
45
+ * Source archive of the workspace subtree (object path → served-object SRI)
46
+ * published beside the dists when the runtime opts in via
47
+ * `publish.sourcePublish` — lets `bos import` dereference a published
48
+ * config slot to the exact source bytes that build came from, with no
49
+ * GitHub dependency. Readers that predate the field ignore it (zod strips
50
+ * unknown keys). Participates in the version hash.
51
+ */
52
+ source: z
53
+ .object({
54
+ file: z.string().min(1),
55
+ integrity: SRI,
56
+ })
57
+ .optional(),
44
58
  });
45
59
  export type WorkspaceVersionManifest = z.infer<typeof WorkspaceVersionManifestSchema>;
46
60
 
61
+ /** A published-artifact coordinate: object path within the bundle namespace + its served SRI. */
62
+ export type SourceRef = NonNullable<WorkspaceVersionManifest["source"]>;
63
+
47
64
  export interface VersionManifestInput {
48
65
  /** Omit: a per-build timestamp makes the same version id map to different
49
66
  * manifest bytes per machine, breaking the pin's SRI against the image's
@@ -57,6 +74,7 @@ export interface VersionManifestInput {
57
74
  assets?: Record<string, string>;
58
75
  shared?: Record<string, string>;
59
76
  files?: Record<string, string>;
77
+ source?: { file: string; integrity: string };
60
78
  }
61
79
 
62
80
  /**
@@ -67,7 +85,7 @@ export interface VersionManifestInput {
67
85
  * coordinate → a new version (and a new file).
68
86
  */
69
87
  export function composeVersionManifest(input: VersionManifestInput): WorkspaceVersionManifest {
70
- const { builtAt, ...hashedInputs } = input;
88
+ const { builtAt: _builtAt, ...hashedInputs } = input;
71
89
  const version = createHash("sha256")
72
90
  .update(stableStringify(hashedInputs))
73
91
  .digest("hex")