Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/app-framework/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
},
"dependencies": {
"@we/ai-context": "workspace:*",
"@we/cesium-layers": "workspace:*",
"@we/components": "workspace:*",
"@we/design-types": "workspace:*",
"@we/design-utils": "workspace:*",
Expand All @@ -53,7 +52,9 @@
"gsap": "^3.13.0",
"solid-js": "^1.9.5",
"three": "^0.176.0",
"zod": "^4.1.11"
"zod": "^4.1.11",
"@we/module-globe": "workspace:*",
"@we/module-notes": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.10.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import type { PerspectiveProxy } from '@coasys/ad4m';
import { createAd4mDataBindings } from '@shared/ad4mAdapter';
import { queryIRFlag } from '@shared/queryIRFlag';
import { getModel } from '@shared/registries/modelRegistry';
import { shellRegistry } from '@shared/registries/shellRegistry';
import { moduleStores } from '@shared/registries/moduleRegistry';
import { slotRegistry } from '@shared/registries/slotRegistry';
import { componentRegistry as registry } from '@solid/registries/componentRegistry';
import {
useAdamStore,
Expand Down Expand Up @@ -79,6 +80,10 @@ export default function TemplateProvider() {
templateStore,
routeStore,
presenceStore,
// Always present, even with no modules registered: `{ $store: 'modules.x' }` resolves through the
// single-segment path, which indexes the store object without a guard and would throw on a
// missing `modules` key rather than returning undefined.
modules: moduleStores,
consoleStore,
model: modelStore,
// Host wiring, not backend adaptation — any backend would wire these the same way, so they stay
Expand Down Expand Up @@ -111,11 +116,12 @@ export default function TemplateProvider() {
return [getModel(modelName), resolvePerspective(opts?.perspective) ?? adamStore.currentPerspective()!] as const;
}

// Shell chrome — boot screen + sidebar + template editor.
// Rendered once outside the keyed Router so it never remounts on template switches.
// Shell chrome — host slots plus anything feature modules contribute.
// Rendered once outside the keyed Router so it never remounts on template switches; that isolation
// is why a template has no channel into the shell.
const shellSchema: TemplateSchema = {
meta: { name: 'Shell', description: 'App shell chrome', icon: '' },
children: [shellRegistry.bootScreen, shellRegistry.sidebar, shellRegistry.templateEditor],
children: slotRegistry.nodes(),
};

const notFoundNode = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,6 @@ import {
TaskDisplay,
VideoDisplay,
} from '@we/block-solid';
import {
countryOutlinesLayer,
h3HexagonsLayer,
type LayerFactory,
pointLocationsLayer,
proceduralStarsLayer,
skyboxLayer,
solarSystemLayer,
} from '@we/cesium-layers';
import {
Accordion,
AvatarStack,
Expand Down Expand Up @@ -50,6 +41,7 @@ import {
Timeline,
ToastContainer,
} from '@we/components/solid';
import { layerFactoryRegistry } from '@we/module-globe';
import type { ComponentRegistry } from '@we/schema-solid';
import { CesiumGlobe, CollapsibleSidebar, GraphWidget, mockGraphData, SpaceSidebarWidget } from '@we/widgets/solid';

Expand All @@ -59,17 +51,11 @@ import { DesignToolbar } from '../components/editor/DesignToolbar';
import { RightPanelContainer } from '../components/editor/RightPanelContainer';
import { TemplateCard } from '../components/marketplace/TemplateCard';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const layerFactoryRegistry: Record<string, LayerFactory<any>> = {
// Planet layers
pointLocationsLayer,
countryOutlinesLayer,
h3HexagonsLayer,
// Background layers
skyboxLayer,
proceduralStarsLayer,
solarSystemLayer,
};
/**
* The globe's layer set moved to `@we/module-globe` — the module owns it now. Re-exported so existing
* importers keep working.
*/
export { layerFactoryRegistry };

export const componentRegistry: ComponentRegistry = {
// @we/components
Expand Down Expand Up @@ -102,6 +88,9 @@ export const componentRegistry: ComponentRegistry = {
RightPanelContainer,
SpaceSidebarWidget,
CollapsibleSidebar,
// Contributed by @we/module-globe — registered here rather than injected by the module registry so
// the static registry stays the single source for what a template may name. When modules become
// installable this entry comes from moduleRegistry.components() instead.
CesiumGlobe: (props) => <CesiumGlobe {...props} layerFactoryRegistry={layerFactoryRegistry} />,
GraphWidget: (props) => <GraphWidget {...props} data={props.data || mockGraphData} />,
SignalControl,
Expand Down
14 changes: 13 additions & 1 deletion packages/app-framework/src/frameworks/solid/stores/AdamStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import {
import { buildModelClasses, buildModelManifest, getForeignShacl } from '@shared/perspectiveHelpers';
import { usePlatform } from '@shared/platform';
import { registerDynamicModels } from '@shared/registries/modelRegistry';
import { deduplicateSpaceSdna, installRootSdna, installSpaceSdna, isModelRegistered } from '@shared/sdnaModels';
import {
deduplicateSpaceSdna,
installModuleSdna,
installRootSdna,
installSpaceSdna,
isModelRegistered,
} from '@shared/sdnaModels';
import {
isSpaceSelf,
type LocationData,
Expand Down Expand Up @@ -1296,6 +1302,12 @@ export function AdamStoreProvider(props: ParentProps) {
await new Promise((resolve) => setTimeout(resolve, 500));
isWeSpace = await isModelRegistered(perspective, Space);
}
} else {
// An existing WE space skips the install above by design, so a module enabled after the space
// was created would find its shapes missing — a query failing with "No SHACL shape stored for
// class X" in a perspective that otherwise looks healthy. Module shapes therefore install on
// every switch; `ensureModelsRegistered` diffs first, so this is a read in the common case.
await installModuleSdna(perspective);
}

// SDNA is installed — switch immediately so WE templates render. WE model classes
Expand Down
41 changes: 36 additions & 5 deletions packages/app-framework/src/shared/initializeIntegrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,32 @@
* iframe mounting) is handled by AppStore and TemplateProvider at runtime.
*/

import type { ModuleStoreDeps } from '@we/schema-shared';

import weSeedFile from '../../../../we-seed.json';
import type { WeSeedFile } from '../types/seed';
import { generateIframePermissions, validateSeedForLauncher } from './integrationComposer';
import type { PlatformAdapter } from './platform/types';
import { appRegistry } from './registries/appRegistry';
import { shellRegistry } from './registries/shellRegistry';
import { activateSeedModules } from './registries/bundledModules';
import { moduleRegistry } from './registries/moduleRegistry';
import { slotRegistry } from './registries/slotRegistry';

export interface IntegrationDeps {
/**
* Framework components a bundled module needs to describe itself, supplied by the caller.
*
* Injected rather than imported because this file is framework-neutral `shared/` code — importing
* the Solid component registry here would drag the whole component tree into it. It is also what
* keeps Solid and `@we/widgets` single instances shared with the host, which is the property that
* starts to matter once modules load dynamically.
*/
components?: Record<string, unknown>;
/** Reactivity lent to module stores, so a module needn't import a framework. */
storeDeps?: ModuleStoreDeps;
}

export function initializeIntegrations(platformAdapter: PlatformAdapter): void {
export function initializeIntegrations(platformAdapter: PlatformAdapter, deps: IntegrationDeps = {}): void {
try {
const seed = weSeedFile as unknown as WeSeedFile;

Expand All @@ -23,9 +41,10 @@ export function initializeIntegrations(platformAdapter: PlatformAdapter): void {
return;
}

// Apply optional white-label boot screen override
// Apply optional white-label boot screen override. Deployment-level configuration of shell
// chrome — the layer whose scope matches, unlike a per-space template.
if (seed.host?.ui?.bootScreen) {
shellRegistry.bootScreen = seed.host.ui.bootScreen;
slotRegistry.replace('core:bootScreen', seed.host.ui.bootScreen);
}

// Resolve each app URL once and register it
Expand All @@ -42,7 +61,19 @@ export function initializeIntegrations(platformAdapter: PlatformAdapter): void {
});
}

console.log(`✓ ${seed.project.name} initialized — ${seed.apps.length} embedded app(s)`);
// Activate the feature modules this deployment declares. Components are passed in rather than
// imported by each module, so Solid and @we/widgets stay single instances shared with the host.
const { activated } = activateSeedModules(
seed.modules,
{ components: deps.components ?? {}, storeDeps: deps.storeDeps },
{ backend: 'ad4m', framework: 'solid' },
moduleRegistry,
);

console.log(
`✓ ${seed.project.name} initialized — ${seed.apps.length} embedded app(s)` +
(activated.length ? `, ${activated.length} module(s): ${activated.join(', ')}` : ''),
);
} catch (error) {
console.error('❌ Failed to initialize integrations:', error);
}
Expand Down
12 changes: 10 additions & 2 deletions packages/app-framework/src/shared/platform/context.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createContext, ParentComponent, useContext } from 'solid-js';
import { componentRegistry } from '@solid/registries/componentRegistry';
import { createContext, createSignal, ParentComponent, useContext } from 'solid-js';

import { initializeIntegrations } from '../initializeIntegrations';
import { PlatformAdapter } from './types';
Expand All @@ -8,7 +9,14 @@ const PlatformContext = createContext<PlatformAdapter>();
export const PlatformProvider: ParentComponent<{ adapter: PlatformAdapter }> = (props) => {
// Initialize integrations with platform adapter BEFORE rendering children
// This must run synchronously so the launcher template is ready when TemplateStoreProvider reads the registry
initializeIntegrations(props.adapter);
// Components are handed over here, where the framework is known — `initializeIntegrations` itself
// stays framework-neutral.
initializeIntegrations(props.adapter, {
components: { CesiumGlobe: componentRegistry.CesiumGlobe },
// Reactivity lent to module stores. Solid's createSignal already has the [read, write] shape the
// port asks for, so a module store gets reactivity without importing a framework.
storeDeps: { signal: <T,>(initial: T) => createSignal(initial) as [() => T, (next: T) => void] },
});

return <PlatformContext.Provider value={props.adapter}>{props.children}</PlatformContext.Provider>;
};
Expand Down
86 changes: 86 additions & 0 deletions packages/app-framework/src/shared/registries/bundledModules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* The feature modules compiled into this build, and the boot-time registration that activates the
* subset a deployment's seed asks for.
*
* Bundled rather than dynamically loaded, deliberately: the registry only needs static registration
* until the marketplace exists, and `import()` brings a whole separate problem (a dynamically-loaded
* bundle carrying its own reactive runtime gets a *second* one, and reactivity silently stops crossing
* the boundary). Solving that is worth doing against a real module, not speculatively.
*
* Adding a module here plus an id in `we-seed.json` is the whole install story for now.
*/
import { createGlobeModule } from '@we/module-globe';
import { notesModule } from '@we/module-notes';
import type { ModuleDefinition, ModuleStoreDeps } from '@we/schema-shared';

/**
* Factories rather than definitions, because a module may need something from the host to describe
* itself — the globe takes the `CesiumGlobe` component so its own package never imports Solid or
* `@we/widgets`, keeping those single instances shared with the host.
*/
export type BundledModuleFactory = (deps: BundledModuleDeps) => ModuleDefinition;

export interface BundledModuleDeps {
/** Framework components the host already holds, passed in rather than imported by the module. */
components: Record<string, unknown>;
}

export interface ActivationDeps extends BundledModuleDeps {
/** Reactivity lent to module stores, so a module needn't import a framework. */
storeDeps?: ModuleStoreDeps;
}

export const bundledModules: Record<string, BundledModuleFactory> = {
globe: ({ components }) => createGlobeModule(components.CesiumGlobe),
// Takes nothing from the host: every piece of its UI is a schema fragment, so it imports no
// framework at all.
notes: () => notesModule,
};

export interface ModuleActivation {
activated: string[];
/** Ids the seed asked for that this build doesn't contain. */
missing: string[];
/** Ids that were refused as incompatible, with the reason. */
refused: { id: string; problems: string[] }[];
}

/**
* Activate the modules a seed declares.
*
* Reports rather than throws. A deployment naming a module this build lacks is a configuration
* mistake, not a reason to fail boot — and a silently missing module surfaces much later as an
* unexplained missing component, which is precisely the confusion the renderer's placeholder now
* names.
*/
export function activateSeedModules(
ids: string[] | undefined,
deps: ActivationDeps,
host: { backend: string; framework: string },
registry: {
register: (
definition: ModuleDefinition,
host: { backend: string; framework: string },
storeDeps?: ModuleStoreDeps,
) => { registered: boolean; problems: string[] };
},
): ModuleActivation {
const result: ModuleActivation = { activated: [], missing: [], refused: [] };

for (const id of ids ?? []) {
const factory = bundledModules[id];
if (!factory) {
result.missing.push(id);
continue;
}
const outcome = registry.register(factory(deps), host, deps.storeDeps);
if (outcome.registered) result.activated.push(id);
else result.refused.push({ id, problems: outcome.problems });
}

if (result.missing.length) {
console.warn(`seed declares modules not present in this build: ${result.missing.join(', ')}`);
}

return result;
}
11 changes: 11 additions & 0 deletions packages/app-framework/src/shared/registries/modelRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ export function registerModel(name: string, modelClass: ModelClass): void {
modelRegistry[name] = modelClass;
}

/**
* Remove a globally registered model — a feature module being unregistered.
*
* Note this only detaches the *class*; any SDNA already installed in a perspective, and any data
* written through it, remain. Removing those is a separate decision (uninstall semantics) that has to
* be made deliberately rather than as a side effect of disabling a module.
*/
export function unregisterModel(name: string): void {
delete modelRegistry[name];
}

export function getModel(name: string): ModelClass {
const model = modelRegistry[name];
if (!model) throw new Error(`Model "${name}" not found in registry. Did you call registerModel()?`);
Expand Down
Loading
Loading