import { type ActorRefFrom, assertEvent, assign, enqueueActions, setup, } from "xstate"; import type { Application } from "../core/applications/application-list"; import { logger } from "../core/log"; import { type FederationInstance } from "./module-federation"; import { moduleLoaderLogic } from "./module-loader.machine"; import { serviceLogic } from "./service.machine"; /** * @internal */ export interface RemoteInput { /** The application this remote represents. */ app: Application; /** The shared federation instance, supplied by the remotes supervisor. */ instance: FederationInstance; } type RemoteContext = { app: Application; instance: FederationInstance; /** * Loaders for exposes an island has rendered — a panel or the App view — * keyed by moduleId. Each is a {@link moduleLoaderLogic}, read via `useRemote`. */ exposes: Map>; /** Running workers, keyed by service name — one {@link serviceLogic} each. */ services: Map>; }; type RemoteEvent = // The app's data changed (e.g. a dev server's interfaces) — re-warm + reconcile. | { type: "app.update"; app: Application } // Load (evaluate) an expose as its island mounts — a panel or the App view. | { type: "expose.load.request"; moduleId: string } // Warm an expose's assets ahead of render — e.g. the App view on hover. | { type: "expose.preload.request"; moduleId: string } // Sent by a child loader on settle; reserved for future supervision. | { type: "remote.settled"; moduleId: string; status: "loaded" | "error" }; /** Every panel view component's moduleId. */ function panelModuleIds(app: Application): string[] { return app .interfaces("panel") .flatMap((view) => (["title", "panel"] as const).map((component) => app.resolveViewModuleId(view, component), ), ) .filter((moduleId): moduleId is string => moduleId !== null); } /** The expose id within a remote — the moduleId after the remote name. */ function exposeOf(moduleId: string): string { return moduleId.slice(moduleId.indexOf("/") + 1); } /** Best-effort asset warm — a preload failure only logs, since a real load resurfaces it. */ function preload( { app, instance }: Pick, exposes: string[], ): void { void instance.preloadRemote(app.id, exposes).catch((error) => { logger.debug(`Preload failed for "${app.id}"`, { exposes, error }); }); } /** * One federated application's remote — spawned per app by the {@link * remotesLogic} supervisor, which supplies the shared federation instance. Each * kind of interface is warmed or run at a different moment: * * - worker — loaded and started on spawn (a service runs while its app exists) * - panel — assets preloaded on spawn; loaded (evaluated) when the panel opens * - App view — assets preloaded on hover; loaded on navigation * * So only workers evaluate at boot; panels and the App view warm their assets * and defer evaluation to render. Registration happens first, so any later * load/preload resolves a known remote. Workers and loaded exposes are children, * disposed by cascade when the app leaves the list; `app.update` re-warms panels * and reconciles workers. * * @internal */ export const remoteLogic = setup({ types: { input: {} as RemoteInput, context: {} as RemoteContext, events: {} as RemoteEvent, }, actors: { moduleLoader: moduleLoaderLogic, service: serviceLogic, }, actions: { /** Register this app's remote so a load/preload can resolve its exposes. */ registerSelf: ({ context }) => { context.instance.registerRemotes([ { name: context.app.id, entry: context.app.url.origin }, ]); }, updateApp: assign({ app: ({ event }) => { assertEvent(event, "app.update"); return event.app; }, }), /** Warm every panel's assets so opening one is wait-free; evaluation waits for render. */ preloadPanels: ({ context }) => { const exposes = panelModuleIds(context.app).map(exposeOf); if (exposes.length > 0) preload(context, exposes); }, /** Warm one expose's assets on request (e.g. the App view on hover). */ preloadExpose: ({ context, event }) => { assertEvent(event, "expose.preload.request"); preload(context, [exposeOf(event.moduleId)]); }, /** Keep running workers in step with declared services — a removed worker (e.g. deleted in dev) is stopped, terminating its Worker. */ reconcileServices: enqueueActions(({ context, enqueue }) => { const desired = new Map( context.app.interfaces("worker").map((iface) => [iface.name, iface]), ); for (const [name, ref] of context.services) { if (!desired.has(name)) enqueue.stopChild(ref); } enqueue.assign({ services: ({ context: ctx, spawn }) => { const next = new Map>(); for (const [name, ref] of ctx.services) { if (desired.has(name)) next.set(name, ref); } for (const name of desired.keys()) { if (next.has(name)) continue; const key = `${ctx.app.id}:${name}`; next.set( name, spawn("service", { id: key, systemId: `services.${key}`, input: { key, appId: ctx.app.id, serviceName: name, entry: ctx.app.url.origin, instance: ctx.instance, }, }), ); } return next; }, }); }), /** Spawn (and thereby load) the expose's loader on first request; a repeat is a no-op. */ ensureExpose: enqueueActions(({ context, event, enqueue }) => { assertEvent(event, "expose.load.request"); if (context.exposes.has(event.moduleId)) return; enqueue.assign({ exposes: ({ context: ctx, spawn }) => new Map(ctx.exposes).set( event.moduleId, spawn("moduleLoader", { id: event.moduleId, systemId: `remotes.${event.moduleId}`, input: { moduleId: event.moduleId, instance: ctx.instance }, }), ), }); }), }, }).createMachine({ id: "remote", context: ({ input }) => ({ app: input.app, instance: input.instance, exposes: new Map(), services: new Map(), }), // Register first, so the panel preloads (and later loads) resolve a known remote. entry: [ { type: "registerSelf" }, { type: "preloadPanels" }, { type: "reconcileServices" }, ], on: { "app.update": { actions: [ { type: "updateApp" }, { type: "preloadPanels" }, { type: "reconcileServices" }, ], }, "expose.load.request": { actions: [{ type: "ensureExpose" }] }, "expose.preload.request": { actions: [{ type: "preloadExpose" }] }, "remote.settled": {}, }, });