@lorion-org/react 1.0.0-beta.2 → 1.0.0-beta.4

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.
package/src/vite.ts CHANGED
@@ -1,40 +1,80 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { dirname, join, relative, resolve, sep } from 'node:path';
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
3
  import process from 'node:process';
4
- import {
5
- createCompositionSelection,
6
- createDescriptorCatalog,
7
- resolveDescriptorSelectionSeed,
8
- type CompositionPolicy,
9
- type Descriptor,
10
- type DescriptorId,
11
- type DescriptorSelectionSeedInput,
12
- type RelationDescriptor,
4
+ import { loadEnv } from 'vite';
5
+ import type {
6
+ CompositionPolicy,
7
+ Descriptor,
8
+ DescriptorId,
9
+ DescriptorSelectionSeedInput,
10
+ RelationDescriptor,
13
11
  } from '@lorion-org/composition-graph';
14
12
  import {
15
13
  descriptorSchema,
16
14
  discoverDescriptors,
15
+ requirePackageName,
17
16
  type DiscoveredDescriptor,
18
17
  } from '@lorion-org/descriptor-discovery';
18
+ import { resolveDescriptorSelection, selectDescriptors } from '@lorion-org/descriptor-selection';
19
19
  import {
20
- collectSelectedProviderPreferences,
21
- resolveSelectedProviderRelationPreferences,
22
- type ProviderPreferenceMap,
23
- } from '@lorion-org/provider-selection';
20
+ createRuntimeConfigValidatorRegistry,
21
+ projectRuntimeConfigNamespaces,
22
+ resolveRuntimeConfigValidationMode,
23
+ toRuntimeConfigFragment,
24
+ toSnakeUpperCase,
25
+ type RuntimeConfigFragment,
26
+ type RuntimeConfigFragmentMap,
27
+ type RuntimeConfigSection,
28
+ type RuntimeConfigValidationPolicyInput,
29
+ } from '@lorion-org/runtime-config';
24
30
  import {
25
- createCapabilityCompositionPolicy,
26
- defaultCapabilityRelationDescriptors,
27
- } from './relations';
31
+ loadRuntimeConfigSourceTree,
32
+ readJsonFile,
33
+ resolveRuntimeConfigSource as resolveRuntimeConfigVarDirSource,
34
+ type RuntimeConfigPathPatternSource,
35
+ type RuntimeConfigSchemaValidationErrorFormatter,
36
+ } from '@lorion-org/runtime-config-node';
37
+ import { capabilitySpecifier, type ActivationResolver } from '@lorion-org/surface-activation';
28
38
 
29
39
  const virtualModuleId = 'virtual:capabilities';
30
40
  const resolvedVirtualModuleId = `\0${virtualModuleId}`;
41
+ const runtimeConfigModuleId = 'virtual:capability-runtime-config';
42
+ const resolvedRuntimeConfigModuleId = `\0${runtimeConfigModuleId}`;
43
+ const serverRuntimeConfigModuleId = 'virtual:capability-runtime-config/server';
44
+ const resolvedServerRuntimeConfigModuleId = `\0${serverRuntimeConfigModuleId}`;
45
+ const defaultRuntimeConfigFileName = 'capability.runtime.json';
46
+ const defaultRuntimeConfigSchemaFileName = 'capability.schema.json';
47
+
48
+ export type CapabilityActivationEntry = {
49
+ exportName?: string;
50
+ exportSubpath?: string;
51
+ };
52
+
53
+ // Returning a nullish activation marks the capability as graph-only: it takes
54
+ // part in dependency resolution but activates nothing (no import is emitted).
55
+ export type ResolveCapabilityActivation = (input: {
56
+ capabilityDir: string;
57
+ descriptor: Descriptor;
58
+ packageJson: Record<string, unknown>;
59
+ packageName: string;
60
+ }) => CapabilityActivationEntry | null | undefined;
31
61
 
32
62
  export type CapabilityLoaderOptions = {
63
+ // How an active capability's activation is resolved (which module and export to
64
+ // import). Pass EITHER `surface` to reuse a lorion `conventionActivation` resolver
65
+ // for a named surface directly (no per-host adapter), OR the richer `activation`
66
+ // resolver that also sees the descriptor and package.json — not both. A nullish
67
+ // result marks the capability graph-only.
68
+ activation?: ResolveCapabilityActivation;
69
+ // Reuse a `conventionActivation` resolver (from `@lorion-org/surface-activation`)
70
+ // for a named surface, e.g. `{ name: 'web', resolver: conventionActivation({...}) }`.
71
+ surface?: { name: string; resolver: ActivationResolver };
33
72
  capabilitiesDir?: string;
34
73
  baseDescriptors?: readonly DescriptorId[];
35
74
  defaultSelection?: readonly DescriptorId[];
36
75
  policy?: Partial<CompositionPolicy>;
37
76
  relationDescriptors?: readonly RelationDescriptor[];
77
+ runtimeConfig?: false | ReactRuntimeConfigOptions;
38
78
  selected?: readonly DescriptorId[];
39
79
  selectionSeed?: false | CapabilitySelectionSeedOptions;
40
80
  workspaceRoot?: string;
@@ -42,6 +82,40 @@ export type CapabilityLoaderOptions = {
42
82
 
43
83
  export type CapabilitySelectionSeedOptions = Omit<DescriptorSelectionSeedInput, 'defaultValue'>;
44
84
 
85
+ export type ReactRuntimeConfigEnvOptions = {
86
+ env?: Record<string, string | undefined>;
87
+ privatePrefix?: string;
88
+ publicPrefix?: string;
89
+ };
90
+
91
+ export type ReactRuntimeConfigValidationOptions = {
92
+ formatError?: RuntimeConfigSchemaValidationErrorFormatter;
93
+ policy?: RuntimeConfigValidationPolicyInput;
94
+ schemaFileName?: string;
95
+ };
96
+
97
+ export type ReactRuntimeConfigVarDirOptions = {
98
+ defaultValue?: string;
99
+ env?: Record<string, string | undefined>;
100
+ envKey?: string;
101
+ value?: string;
102
+ };
103
+
104
+ export type ReactRuntimeConfigOptions = {
105
+ configFileName?: string;
106
+ enabled?: boolean;
107
+ env?: false | ReactRuntimeConfigEnvOptions;
108
+ schemaFileName?: string;
109
+ source?: false | RuntimeConfigPathPatternSource;
110
+ validation?: false | ReactRuntimeConfigValidationOptions;
111
+ varDir?: string | ReactRuntimeConfigVarDirOptions;
112
+ };
113
+
114
+ export type ReactRuntimeConfig = {
115
+ private: Record<string, RuntimeConfigSection>;
116
+ public: Record<string, RuntimeConfigSection>;
117
+ };
118
+
45
119
  export type CapabilityRouteConfigOptions = CapabilityLoaderOptions & {
46
120
  indexRouteFile?: false | string;
47
121
  routesDirectory: string;
@@ -56,10 +130,13 @@ export type LorionReactViteSetup = {
56
130
  };
57
131
 
58
132
  export type DiscoveredCapability = {
133
+ capabilityDir: string;
59
134
  disabled: boolean;
60
- entryFile: string;
135
+ entryFile?: string;
136
+ // Absent for graph-only capabilities that resolve but do not activate.
137
+ exportName?: string;
61
138
  id: string;
62
- importSpecifier: string;
139
+ importSpecifier?: string;
63
140
  manifest: Descriptor;
64
141
  packageName: string;
65
142
  routesDirectory?: string;
@@ -84,13 +161,16 @@ export type VirtualRootRoute = {
84
161
  };
85
162
 
86
163
  export type ViteResolvedConfig = {
164
+ env?: Record<string, string | undefined>;
165
+ envDir?: false | string;
166
+ mode?: string;
87
167
  root: string;
88
168
  };
89
169
 
90
170
  export type VitePlugin = {
91
171
  configResolved: (resolvedConfig: ViteResolvedConfig) => void;
92
172
  enforce: 'pre';
93
- load: (id: string) => string | null;
173
+ load: (id: string, options?: { ssr?: boolean | undefined }) => string | null;
94
174
  name: string;
95
175
  resolveId: (id: string) => string | undefined;
96
176
  };
@@ -98,6 +178,7 @@ export type VitePlugin = {
98
178
  export function capabilityLoader(options: CapabilityLoaderOptions = {}): VitePlugin {
99
179
  let config: ViteResolvedConfig;
100
180
  let capabilities: DiscoveredCapability[] = [];
181
+ let runtimeConfig: ReactRuntimeConfig = { private: {}, public: {} };
101
182
 
102
183
  return {
103
184
  name: 'lorion-react-capability-loader',
@@ -108,13 +189,31 @@ export function capabilityLoader(options: CapabilityLoaderOptions = {}): VitePlu
108
189
  resolveWorkspaceRoot(config.root, options),
109
190
  options,
110
191
  );
192
+ runtimeConfig = createReactRuntimeConfig(
193
+ capabilities,
194
+ resolveWorkspaceRoot(config.root, options),
195
+ options.runtimeConfig,
196
+ config,
197
+ );
111
198
  },
112
199
  resolveId(id) {
113
200
  if (id === virtualModuleId) return resolvedVirtualModuleId;
201
+ if (id === runtimeConfigModuleId) return resolvedRuntimeConfigModuleId;
202
+ if (id === serverRuntimeConfigModuleId) return resolvedServerRuntimeConfigModuleId;
114
203
 
115
204
  return capabilities.find((capability) => capability.importSpecifier === id)?.entryFile;
116
205
  },
117
- load(id) {
206
+ load(id, loadOptions) {
207
+ if (id === resolvedRuntimeConfigModuleId) return renderRuntimeConfigModule(runtimeConfig);
208
+ if (id === resolvedServerRuntimeConfigModuleId) {
209
+ if (!loadOptions?.ssr) {
210
+ throw new Error(
211
+ 'virtual:capability-runtime-config/server may only be imported from SSR/server code.',
212
+ );
213
+ }
214
+
215
+ return renderServerRuntimeConfigModule(runtimeConfig);
216
+ }
118
217
  if (id !== resolvedVirtualModuleId) return null;
119
218
 
120
219
  return renderCapabilityModule(capabilities, resolveSelectionSeed(options));
@@ -122,6 +221,49 @@ export function capabilityLoader(options: CapabilityLoaderOptions = {}): VitePlu
122
221
  };
123
222
  }
124
223
 
224
+ export function createReactRuntimeConfig(
225
+ capabilities: readonly DiscoveredCapability[],
226
+ workspaceRoot: string,
227
+ options: false | ReactRuntimeConfigOptions = {},
228
+ viteConfig: Partial<ViteResolvedConfig> = {},
229
+ ): ReactRuntimeConfig {
230
+ const normalizedOptions = normalizeRuntimeConfigOptions(options);
231
+ if (!normalizedOptions.enabled) return { private: {}, public: {} };
232
+
233
+ const runtimeEnv = resolveRuntimeConfigEnv(viteConfig, normalizedOptions.env ?? {});
234
+ const sourceFragments = normalizedOptions.source
235
+ ? loadRuntimeConfigSourceTree(
236
+ resolveRuntimeConfigPatternSource(workspaceRoot, normalizedOptions, runtimeEnv),
237
+ )
238
+ : new Map<string, RuntimeConfigFragment>();
239
+ const schemaEntries = readRuntimeConfigSchemas(capabilities, normalizedOptions.schemaFileName);
240
+ const envFragments =
241
+ normalizedOptions.env === false
242
+ ? new Map<string, RuntimeConfigFragment>()
243
+ : createRuntimeConfigEnvFragments(
244
+ createRuntimeConfigEnvFragmentOptions(
245
+ capabilities,
246
+ schemaEntries.schemas,
247
+ runtimeEnv,
248
+ normalizedOptions.env,
249
+ ),
250
+ );
251
+ const fragments = mergeRuntimeConfigFragmentMaps(sourceFragments, envFragments);
252
+
253
+ validateReactRuntimeConfig(capabilities, fragments, schemaEntries, normalizedOptions.validation);
254
+
255
+ const projected = projectRuntimeConfigNamespaces(fragments, {
256
+ namespaceStrategy: 'nested',
257
+ scopeIds: capabilities.map((capability) => capability.id),
258
+ });
259
+ const { public: publicConfig, ...privateConfig } = projected;
260
+
261
+ return {
262
+ public: publicConfig as Record<string, RuntimeConfigSection>,
263
+ private: privateConfig as Record<string, RuntimeConfigSection>,
264
+ };
265
+ }
266
+
125
267
  export function lorionReact(options: LorionReactViteOptions): LorionReactViteSetup {
126
268
  return {
127
269
  capabilityLoader: capabilityLoader(options),
@@ -129,9 +271,28 @@ export function lorionReact(options: LorionReactViteOptions): LorionReactViteSet
129
271
  };
130
272
  }
131
273
 
274
+ // Normalizes the activation option into a ResolveCapabilityActivation. With
275
+ // `surface`, `resolver` is a lorion ActivationResolver (from conventionActivation)
276
+ // resolved for that surface, so a host reuses the shared surface convention
277
+ // directly with no adapter. Without `surface`, it is the richer `activation`
278
+ // resolver. The two are mutually exclusive.
279
+ function toResolveActivation(
280
+ options: Pick<CapabilityLoaderOptions, 'activation' | 'surface'>,
281
+ ): ResolveCapabilityActivation | undefined {
282
+ if (options.surface) {
283
+ if (options.activation) {
284
+ throw new Error('capabilityLoader: pass either `surface` or `activation`, not both.');
285
+ }
286
+ const { name, resolver } = options.surface;
287
+ return ({ capabilityDir, descriptor }) =>
288
+ resolver(name, { directory: capabilityDir, id: descriptor.id });
289
+ }
290
+ return options.activation;
291
+ }
292
+
132
293
  export function discoverCapabilities(
133
294
  workspaceRoot: string,
134
- options: Pick<CapabilityLoaderOptions, 'capabilitiesDir'> = {},
295
+ options: Pick<CapabilityLoaderOptions, 'activation' | 'capabilitiesDir' | 'surface'> = {},
135
296
  ): DiscoveredCapability[] {
136
297
  const capabilitiesRoot = resolve(workspaceRoot, options.capabilitiesDir ?? 'capabilities');
137
298
 
@@ -139,8 +300,9 @@ export function discoverCapabilities(
139
300
  throw new Error(`Capabilities directory not found: ${capabilitiesRoot}`);
140
301
  }
141
302
 
303
+ const resolveActivation = toResolveActivation(options);
142
304
  return discoverCapabilityDescriptors(workspaceRoot, options)
143
- .map(discoverCapability)
305
+ .map((entry) => discoverCapability(entry, resolveActivation))
144
306
  .sort((left, right) => left.id.localeCompare(right.id));
145
307
  }
146
308
 
@@ -163,105 +325,39 @@ function selectCapabilities(
163
325
  | 'selectionSeed'
164
326
  > = {},
165
327
  ): DiscoveredCapability[] {
166
- const enabledCapabilities = capabilities.filter((capability) => capability.disabled !== true);
167
- const selected = resolveCapabilitySelectionSeed(options);
168
-
169
- if (!selected.length && !options.baseDescriptors?.length) {
170
- return [...enabledCapabilities];
171
- }
172
-
173
- const selectedProviders = collectSelectedProviderPreferences({
174
- items: enabledCapabilities,
175
- getCapabilityId: (capability) => capability.manifest.providesFor,
176
- getProviderId: (capability) => capability.id,
177
- selectedProviderIds: selected,
178
- });
179
- const selectionCapabilities = createProviderSelectionAwareCapabilities(
180
- enabledCapabilities,
181
- selectedProviders,
182
- );
183
- const catalog = createDescriptorCatalog({
184
- descriptors: selectionCapabilities.map((capability) => capability.manifest),
185
- relationDescriptors: [
186
- ...defaultCapabilityRelationDescriptors,
187
- ...(options.relationDescriptors ?? []),
188
- ],
189
- });
190
- const selection = createCompositionSelection({
191
- catalog,
192
- selected: [...selected],
193
- baseDescriptors: [...(options.baseDescriptors ?? [])],
194
- policy: createCapabilityCompositionPolicy(options.policy),
195
- });
196
- const selectedIds = new Set(selection.getResolved());
197
-
198
- return selectionCapabilities.filter((capability) => selectedIds.has(capability.id));
199
- }
200
-
201
- function createProviderSelectionAwareCapabilities(
202
- capabilities: readonly DiscoveredCapability[],
203
- selectedProviders: ProviderPreferenceMap,
204
- ): DiscoveredCapability[] {
205
- if (!Object.keys(selectedProviders).length) return [...capabilities];
206
-
207
- return capabilities.map((capability) => {
208
- const manifest = { ...capability.manifest };
209
- const preferences = resolveSelectedProviderRelationPreferences({
210
- providerId: capability.id,
211
- defaultFor: manifest.defaultFor,
212
- providerPreferences: manifest.providerPreferences as ProviderPreferenceMap | undefined,
213
- selectedProviders,
214
- });
215
-
216
- delete manifest.defaultFor;
217
- delete manifest.providerPreferences;
218
-
219
- return {
220
- ...capability,
221
- manifest: {
222
- ...manifest,
223
- ...preferences,
224
- },
225
- };
328
+ return selectDescriptors({
329
+ items: capabilities,
330
+ getDescriptor: (capability) => capability.manifest,
331
+ withDescriptor: (capability, manifest) => ({ ...capability, manifest }),
332
+ seed: options,
333
+ ...(options.relationDescriptors ? { relationDescriptors: options.relationDescriptors } : {}),
334
+ ...(options.policy ? { policy: options.policy } : {}),
226
335
  });
227
336
  }
228
337
 
229
338
  function resolveSelectionSeed(
230
339
  options: Pick<CapabilityLoaderOptions, 'defaultSelection' | 'selected' | 'selectionSeed'>,
231
340
  ): DescriptorId[] {
232
- return resolveCapabilitySelectionSeed(options);
233
- }
234
-
235
- function resolveCapabilitySelectionSeed(
236
- options: Pick<CapabilityLoaderOptions, 'defaultSelection' | 'selected' | 'selectionSeed'>,
237
- ): DescriptorId[] {
238
- if (options.selected?.length) return [...options.selected];
239
-
240
- if (options.selectionSeed === false) return [...(options.defaultSelection ?? [])];
241
-
242
- const seedOptions = options.selectionSeed ?? {};
243
- const selected = resolveDescriptorSelectionSeed({
244
- argv: seedOptions.argv ?? process.argv,
245
- env: seedOptions.env ?? process.env,
246
- key: seedOptions.key ?? 'capability',
247
- ...(seedOptions.cliKeys ? { cliKeys: seedOptions.cliKeys } : {}),
248
- ...(seedOptions.envKeys ? { envKeys: seedOptions.envKeys } : {}),
249
- });
250
-
251
- return selected.length ? selected : [...(options.defaultSelection ?? [])];
341
+ return resolveDescriptorSelection(options);
252
342
  }
253
343
 
254
344
  export function renderCapabilityModule(
255
345
  capabilities: readonly DiscoveredCapability[],
256
346
  selected: readonly DescriptorId[] = [],
257
347
  ): string {
258
- const imports = capabilities
348
+ // Only capabilities with a resolved activation entry are imported and
349
+ // registered. Graph-only capabilities take part in dependency resolution and
350
+ // still appear in resolvedCapabilityIds, but emit no import.
351
+ const activated = capabilities.filter(
352
+ (capability) => capability.exportName && capability.importSpecifier,
353
+ );
354
+ const imports = activated
259
355
  .map(
260
356
  (capability) =>
261
- `import { capability as ${capability.variableName} } from '${capability.importSpecifier}'`,
357
+ `import { ${capability.exportName} as ${capability.variableName} } from '${capability.importSpecifier}'`,
262
358
  )
263
359
  .join('\n');
264
- const variables = capabilities.map((capability) => ` ${capability.variableName},`).join('\n');
360
+ const variables = activated.map((capability) => ` ${capability.variableName},`).join('\n');
265
361
  const capabilityIds = capabilities.map((capability) => capability.id);
266
362
 
267
363
  return `${imports}
@@ -276,6 +372,18 @@ ${variables}
276
372
  `;
277
373
  }
278
374
 
375
+ export function renderRuntimeConfigModule(runtimeConfig: ReactRuntimeConfig): string {
376
+ return `export const capabilityRuntimeConfig = ${JSON.stringify({ public: runtimeConfig.public })}
377
+
378
+ export const publicCapabilityRuntimeConfig = capabilityRuntimeConfig.public
379
+ `;
380
+ }
381
+
382
+ export function renderServerRuntimeConfigModule(runtimeConfig: ReactRuntimeConfig): string {
383
+ return `export const capabilityServerRuntimeConfig = ${JSON.stringify(runtimeConfig)}
384
+ `;
385
+ }
386
+
279
387
  export function createCapabilityRouteConfig(
280
388
  options: CapabilityRouteConfigOptions,
281
389
  ): VirtualRootRoute {
@@ -330,7 +438,10 @@ function discoverCapabilityDescriptors(
330
438
  });
331
439
  }
332
440
 
333
- function discoverCapability(entry: DiscoveredDescriptor): DiscoveredCapability {
441
+ function discoverCapability(
442
+ entry: DiscoveredDescriptor,
443
+ resolveActivation?: ResolveCapabilityActivation,
444
+ ): DiscoveredCapability {
334
445
  const capabilityDir = entry.cwd;
335
446
  const packagePath = resolve(capabilityDir, 'package.json');
336
447
 
@@ -341,27 +452,350 @@ function discoverCapability(entry: DiscoveredDescriptor): DiscoveredCapability {
341
452
  }
342
453
 
343
454
  const packageJson = readJson(packagePath);
344
- const packageName = packageJson.name;
455
+ const packageName = requirePackageName(packageJson, packagePath);
345
456
 
346
- if (typeof packageName !== 'string') {
347
- throw new Error(`Capability package is missing "name": ${packagePath}`);
348
- }
349
-
350
- const activationEntry = resolveActivationEntry(capabilityDir, packageJson);
457
+ const activationEntry = resolveActivationEntry(
458
+ capabilityDir,
459
+ packageJson,
460
+ packageName,
461
+ entry.descriptor,
462
+ resolveActivation,
463
+ );
351
464
  const routesDirectory = resolveRouteDirectory(capabilityDir);
352
465
 
353
466
  return {
467
+ capabilityDir,
354
468
  id: entry.descriptor.id,
355
469
  disabled: entry.descriptor.disabled === true,
356
- entryFile: activationEntry.entryFile,
357
- importSpecifier: activationEntry.importSpecifier,
358
470
  manifest: entry.descriptor,
359
471
  packageName,
472
+ ...(activationEntry
473
+ ? {
474
+ exportName: activationEntry.exportName,
475
+ importSpecifier: activationEntry.importSpecifier,
476
+ ...(activationEntry.entryFile ? { entryFile: activationEntry.entryFile } : {}),
477
+ }
478
+ : {}),
360
479
  ...(routesDirectory ? { routesDirectory } : {}),
361
480
  variableName: toVariableName(entry.descriptor.id),
362
481
  };
363
482
  }
364
483
 
484
+ type NormalizedReactRuntimeConfigOptions = {
485
+ configFileName: string;
486
+ enabled: boolean;
487
+ env: false | ReactRuntimeConfigEnvOptions;
488
+ schemaFileName: string;
489
+ source?: RuntimeConfigPathPatternSource;
490
+ validation: false | ReactRuntimeConfigValidationOptions;
491
+ varDir?: string | ReactRuntimeConfigVarDirOptions;
492
+ };
493
+
494
+ type RuntimeConfigSchemaEntries = {
495
+ paths: Map<string, string>;
496
+ schemas: Record<string, object>;
497
+ };
498
+
499
+ function normalizeRuntimeConfigOptions(
500
+ options: false | ReactRuntimeConfigOptions,
501
+ ): NormalizedReactRuntimeConfigOptions {
502
+ if (options === false) {
503
+ return {
504
+ configFileName: defaultRuntimeConfigFileName,
505
+ enabled: false,
506
+ env: false,
507
+ schemaFileName: defaultRuntimeConfigSchemaFileName,
508
+ validation: false,
509
+ };
510
+ }
511
+
512
+ const configFileName = options.configFileName ?? defaultRuntimeConfigFileName;
513
+
514
+ return {
515
+ configFileName,
516
+ enabled: options.enabled !== false,
517
+ env: options.env ?? {},
518
+ schemaFileName: options.schemaFileName ?? defaultRuntimeConfigSchemaFileName,
519
+ ...(options.source === false
520
+ ? {}
521
+ : {
522
+ source: options.source ?? { paths: [] },
523
+ }),
524
+ validation: options.validation === false ? false : (options.validation ?? {}),
525
+ ...(options.varDir ? { varDir: options.varDir } : {}),
526
+ };
527
+ }
528
+
529
+ function resolveRuntimeConfigPatternSource(
530
+ workspaceRoot: string,
531
+ options: Pick<NormalizedReactRuntimeConfigOptions, 'configFileName' | 'source' | 'varDir'>,
532
+ env: Record<string, string | undefined>,
533
+ ): RuntimeConfigPathPatternSource {
534
+ if (!options.source?.paths.length) {
535
+ const varDir = resolveRuntimeConfigVarDir(workspaceRoot, options.varDir, env);
536
+
537
+ return {
538
+ paths: [join(varDir, 'runtime-config', '*', options.configFileName)],
539
+ };
540
+ }
541
+
542
+ return {
543
+ paths: options.source.paths.map((entry: string) =>
544
+ isAbsolute(entry) ? entry : resolve(workspaceRoot, entry),
545
+ ),
546
+ };
547
+ }
548
+
549
+ function resolveRuntimeConfigVarDir(
550
+ workspaceRoot: string,
551
+ options: string | ReactRuntimeConfigVarDirOptions | undefined,
552
+ env: Record<string, string | undefined>,
553
+ ): string {
554
+ const rawVarDir =
555
+ typeof options === 'string'
556
+ ? options
557
+ : resolveRuntimeConfigVarDirSource({
558
+ defaultVarDir: resolve(workspaceRoot, '.data'),
559
+ env: options?.env ?? env,
560
+ envKey: options?.envKey ?? 'RUNTIME_CONFIG_VAR_DIR',
561
+ ...(options?.value ? { varDir: options.value } : {}),
562
+ ...(options?.defaultValue ? { defaultVarDir: options.defaultValue } : {}),
563
+ }).varDir;
564
+
565
+ return isAbsolute(rawVarDir) ? rawVarDir : resolve(workspaceRoot, rawVarDir);
566
+ }
567
+
568
+ function resolveRuntimeConfigEnv(
569
+ viteConfig: Partial<ViteResolvedConfig>,
570
+ options: ReactRuntimeConfigEnvOptions | false,
571
+ ): Record<string, string | undefined> {
572
+ if (options === false) return {};
573
+ if (options.env) return options.env;
574
+
575
+ const mode = viteConfig.mode ?? process.env.NODE_ENV ?? 'development';
576
+ const envDir =
577
+ typeof viteConfig.envDir === 'string' ? viteConfig.envDir : (viteConfig.root ?? process.cwd());
578
+
579
+ return {
580
+ ...loadEnv(mode, envDir, ''),
581
+ ...process.env,
582
+ ...(viteConfig.env ?? {}),
583
+ };
584
+ }
585
+
586
+ function readRuntimeConfigSchemas(
587
+ capabilities: readonly DiscoveredCapability[],
588
+ schemaFileName: string,
589
+ ): RuntimeConfigSchemaEntries {
590
+ const paths = new Map<string, string>();
591
+ const schemas: Record<string, object> = {};
592
+
593
+ for (const capability of capabilities) {
594
+ const schemaPath = resolve(capability.capabilityDir, schemaFileName);
595
+ const schema = readJsonFile<object>(schemaPath, {
596
+ onParseError: (error, filePath) => {
597
+ throw new Error(`RuntimeConfig schema JSON parse error in "${filePath}": ${String(error)}`);
598
+ },
599
+ });
600
+
601
+ if (!schema) continue;
602
+
603
+ paths.set(capability.id, schemaPath);
604
+ schemas[capability.id] = schema;
605
+ }
606
+
607
+ return { paths, schemas };
608
+ }
609
+
610
+ function isJsonSchemaObject(value: unknown): value is { properties?: Record<string, unknown> } {
611
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
612
+ }
613
+
614
+ function getRuntimeConfigSchemaSectionKeys(
615
+ schema: object | undefined,
616
+ visibility: string,
617
+ ): string[] {
618
+ if (!isJsonSchemaObject(schema)) return [];
619
+
620
+ const section = schema.properties?.[visibility];
621
+ if (!isJsonSchemaObject(section) || !isJsonSchemaObject(section.properties)) return [];
622
+
623
+ return Object.keys(section.properties).sort();
624
+ }
625
+
626
+ function createRuntimeConfigEnvKey(input: {
627
+ key: string;
628
+ prefix?: string;
629
+ scopeId: string;
630
+ }): string {
631
+ return toSnakeUpperCase([input.prefix, input.scopeId, input.key].filter(Boolean).join('_'));
632
+ }
633
+
634
+ function createRuntimeConfigEnvFragments(input: {
635
+ capabilities: readonly DiscoveredCapability[];
636
+ env: Record<string, string | undefined>;
637
+ privatePrefix?: string;
638
+ publicPrefix?: string;
639
+ schemas: Record<string, object>;
640
+ }): RuntimeConfigFragmentMap {
641
+ const fragments: RuntimeConfigFragmentMap = new Map();
642
+
643
+ for (const capability of input.capabilities) {
644
+ const schema = input.schemas[capability.id];
645
+ const publicConfig: RuntimeConfigSection = {};
646
+ const privateConfig: RuntimeConfigSection = {};
647
+
648
+ for (const key of getRuntimeConfigSchemaSectionKeys(schema, 'public')) {
649
+ const envKey = createRuntimeConfigEnvKey({
650
+ key,
651
+ prefix: input.publicPrefix ?? 'VITE',
652
+ scopeId: capability.id,
653
+ });
654
+ const value = input.env[envKey];
655
+
656
+ if (value !== undefined) publicConfig[key] = value;
657
+ }
658
+
659
+ for (const key of getRuntimeConfigSchemaSectionKeys(schema, 'private')) {
660
+ const envKey = createRuntimeConfigEnvKey({
661
+ key,
662
+ scopeId: capability.id,
663
+ ...(input.privatePrefix !== undefined ? { prefix: input.privatePrefix } : {}),
664
+ });
665
+ const value = input.env[envKey];
666
+
667
+ if (value !== undefined) privateConfig[key] = value;
668
+ }
669
+
670
+ if (Object.keys(publicConfig).length || Object.keys(privateConfig).length) {
671
+ fragments.set(capability.id, {
672
+ ...(Object.keys(publicConfig).length ? { public: publicConfig } : {}),
673
+ ...(Object.keys(privateConfig).length ? { private: privateConfig } : {}),
674
+ });
675
+ }
676
+ }
677
+
678
+ return fragments;
679
+ }
680
+
681
+ function createRuntimeConfigEnvFragmentOptions(
682
+ capabilities: readonly DiscoveredCapability[],
683
+ schemas: Record<string, object>,
684
+ env: Record<string, string | undefined>,
685
+ options: ReactRuntimeConfigEnvOptions,
686
+ ): Parameters<typeof createRuntimeConfigEnvFragments>[0] {
687
+ return {
688
+ capabilities,
689
+ env,
690
+ ...(options.privatePrefix !== undefined ? { privatePrefix: options.privatePrefix } : {}),
691
+ ...(options.publicPrefix !== undefined ? { publicPrefix: options.publicPrefix } : {}),
692
+ schemas,
693
+ };
694
+ }
695
+
696
+ function mergeRuntimeConfigSections(
697
+ left: RuntimeConfigSection | undefined,
698
+ right: RuntimeConfigSection | undefined,
699
+ ): RuntimeConfigSection | undefined {
700
+ const section = {
701
+ ...(left ?? {}),
702
+ ...(right ?? {}),
703
+ };
704
+
705
+ return Object.keys(section).length ? section : undefined;
706
+ }
707
+
708
+ function mergeRuntimeConfigFragment(
709
+ left: RuntimeConfigFragment | undefined,
710
+ right: RuntimeConfigFragment | undefined,
711
+ ): RuntimeConfigFragment {
712
+ const publicConfig = mergeRuntimeConfigSections(left?.public, right?.public);
713
+ const privateConfig = mergeRuntimeConfigSections(left?.private, right?.private);
714
+
715
+ return toRuntimeConfigFragment({
716
+ ...(publicConfig ? { public: publicConfig } : {}),
717
+ ...(privateConfig ? { private: privateConfig } : {}),
718
+ });
719
+ }
720
+
721
+ function mergeRuntimeConfigFragmentMaps(
722
+ left: RuntimeConfigFragmentMap,
723
+ right: RuntimeConfigFragmentMap,
724
+ ): RuntimeConfigFragmentMap {
725
+ const fragments: RuntimeConfigFragmentMap = new Map(left);
726
+
727
+ for (const [scopeId, config] of right.entries()) {
728
+ fragments.set(scopeId, mergeRuntimeConfigFragment(fragments.get(scopeId), config));
729
+ }
730
+
731
+ return fragments;
732
+ }
733
+
734
+ function getCapabilityRuntimeConfigPolicy(
735
+ capability: DiscoveredCapability,
736
+ validation: ReactRuntimeConfigValidationOptions,
737
+ ): RuntimeConfigValidationPolicyInput {
738
+ return (
739
+ (capability.manifest.runtimeConfig as RuntimeConfigValidationPolicyInput | undefined) ??
740
+ validation.policy ??
741
+ 'optional'
742
+ );
743
+ }
744
+
745
+ function shouldValidateRuntimeConfigFragment(input: {
746
+ capability: DiscoveredCapability;
747
+ fragment: RuntimeConfigFragment | undefined;
748
+ schema: object | undefined;
749
+ validation: ReactRuntimeConfigValidationOptions;
750
+ }): boolean {
751
+ if (!input.schema) return false;
752
+
753
+ const mode = resolveRuntimeConfigValidationMode(
754
+ getCapabilityRuntimeConfigPolicy(input.capability, input.validation),
755
+ );
756
+
757
+ if (mode === 'none' || mode === 'onUse') return false;
758
+ if (mode === 'startup') return true;
759
+
760
+ return Boolean(input.fragment);
761
+ }
762
+
763
+ function validateReactRuntimeConfig(
764
+ capabilities: readonly DiscoveredCapability[],
765
+ fragments: RuntimeConfigFragmentMap,
766
+ schemas: RuntimeConfigSchemaEntries,
767
+ validation: false | ReactRuntimeConfigValidationOptions,
768
+ ): void {
769
+ if (validation === false) return;
770
+
771
+ const registry = createRuntimeConfigValidatorRegistry(schemas.schemas, {
772
+ ...(validation.formatError
773
+ ? {
774
+ formatError: (target, validationError) =>
775
+ validation.formatError!(
776
+ {
777
+ configPath: `${target.scopeId} runtime config`,
778
+ schemaPath: schemas.paths.get(target.scopeId) ?? '',
779
+ scopeId: target.scopeId,
780
+ },
781
+ validationError,
782
+ ),
783
+ }
784
+ : {}),
785
+ });
786
+
787
+ for (const capability of capabilities) {
788
+ const fragment = fragments.get(capability.id);
789
+ const schema = schemas.schemas[capability.id];
790
+
791
+ if (!shouldValidateRuntimeConfigFragment({ capability, fragment, schema, validation })) {
792
+ continue;
793
+ }
794
+
795
+ registry.assert(capability.id, fragment ?? {});
796
+ }
797
+ }
798
+
365
799
  function resolveRouteDirectory(capabilityDir: string): string | undefined {
366
800
  const routesDirectory = resolve(capabilityDir, 'src', 'routes');
367
801
 
@@ -371,24 +805,36 @@ function resolveRouteDirectory(capabilityDir: string): string | undefined {
371
805
  function resolveActivationEntry(
372
806
  capabilityDir: string,
373
807
  packageJson: Record<string, unknown>,
374
- ): { entryFile: string; importSpecifier: string } {
375
- const packageName = packageJson.name;
376
- const packageExports = packageJson.exports;
808
+ packageName: string,
809
+ descriptor: Descriptor,
810
+ resolveActivation?: ResolveCapabilityActivation,
811
+ ): { entryFile?: string; exportName: string; importSpecifier: string } | null {
812
+ // Without a custom activation resolver the default convention stays strict:
813
+ // the package must declare a string "./capability" export that LORION
814
+ // self-resolves to a local file.
815
+ if (!resolveActivation) {
816
+ const packageExports = packageJson.exports;
817
+
818
+ if (!isRecord(packageExports) || typeof packageExports['./capability'] !== 'string') {
819
+ throw new Error(`Capability package is missing a "./capability" export: ${capabilityDir}`);
820
+ }
377
821
 
378
- if (typeof packageName !== 'string') {
379
- throw new Error(
380
- `Capability package is missing "name": ${resolve(capabilityDir, 'package.json')}`,
381
- );
822
+ return {
823
+ entryFile: resolve(capabilityDir, packageExports['./capability']),
824
+ exportName: 'capability',
825
+ importSpecifier: capabilitySpecifier(packageName, './capability'),
826
+ };
382
827
  }
383
828
 
384
- if (!isRecord(packageExports) || typeof packageExports['./capability'] !== 'string') {
385
- throw new Error(`Capability package is missing a "./capability" export: ${capabilityDir}`);
386
- }
829
+ // A custom resolver may target any package export and leaves specifier
830
+ // resolution to the host bundler. A nullish result marks a graph-only
831
+ // capability: resolved for the dependency graph, but not activated.
832
+ const activation = resolveActivation({ capabilityDir, descriptor, packageJson, packageName });
833
+ if (!activation) return null;
387
834
 
388
- return {
389
- entryFile: resolve(capabilityDir, packageExports['./capability']),
390
- importSpecifier: `${packageName}/capability`,
391
- };
835
+ const exportName = activation.exportName ?? 'capability';
836
+ const exportSubpath = activation.exportSubpath ?? './capability';
837
+ return { exportName, importSpecifier: capabilitySpecifier(packageName, exportSubpath) };
392
838
  }
393
839
 
394
840
  function hasRouteDirectory(