import { assign, fromPromise, sendParent, setup } from "xstate"; import type { FederationInstance } from "./module-federation"; /** * @public */ export interface RemoteRenderOptions { basePath?: string; scheme?: "light" | "dark"; /** * This is a temporary measure designed to authenticate federated * studios & SDK apps before we write a working protocol. */ unstable_temporaryToken?: string | null; } /** * A loaded render-contract module. `TProps` is what its `render` accepts — * `RemoteRenderOptions` for an application's full-page view, the view's props * for a dock-panel component. * @public */ export interface LoadedRemoteModule { render: (rootElement: HTMLElement, props?: TProps) => () => void; } /** * A worker service's loader module — the `worker` interface's expose. Carries * the worker bundle URL (root-relative to the app origin) for the host to * bootstrap, not a render function; the services counterpart of * {@link LoadedRemoteModule}. * @public */ export interface ServiceLoaderModule { url: string; type: string; name: string; version: number; } /** * The federated-module shape each interface type exposes, keyed by * `type`. Every interface — `panel`, `app`, `worker` — loads through * the same {@link moduleLoaderLogic} lifecycle; this maps what its loaded module looks * like so each consumer narrows the machine's shape-agnostic `module` to the * right type: a render contract for `panel`/`app` (rendered by an island), a * worker loader for `worker` (run as a Web Worker, never rendered). * @public */ export interface RemoteModuleByInterfaceType { panel: LoadedRemoteModule; app: LoadedRemoteModule; worker: ServiceLoaderModule; } /** * @public */ export interface RemoteError { message: string; cause: unknown; } /** * Thrown by a render consumer (e.g. the island) when a loaded module doesn't * expose a `render` function. Render-specific — `moduleLoaderLogic` itself is * shape-agnostic, so this lives with the consumer that needs the contract, not * the loader. Surfaced via `RemoteError.cause` for consumers that discriminate. * @public */ export class ModuleShapeError extends Error { constructor(remoteId: string) { super(`Remote "${remoteId}" did not expose a render function`); } } /** * Thrown by the load actor when a remote's expose resolves empty — the module * isn't published, or `loadRemote` returned nothing. Generic across interface * types; the shape-specific check (a render function, a worker URL) is the * consumer's, since `moduleLoaderLogic` loads every interface the same way. * @internal */ export class ModuleLoadError extends Error { constructor(remoteId: string) { super(`Remote module "${remoteId}" failed to load`); } } /** * @internal */ export interface ModuleLoaderInput { /** Fully-qualified module id — `${appId}/${expose}`. One loader per moduleId. */ moduleId: string; instance: FederationInstance; } type ModuleLoaderContext = ModuleLoaderInput & { /** The loaded module, shape-agnostic — each consumer narrows it. @see {@link RemoteModuleByInterfaceType} */ module: unknown; error: RemoteError | null; }; const loadLogic = fromPromise(async ({ input }) => { // Every remote is registered upfront during startup (see the applications // machine), so loading only has to resolve the expose by its moduleId. const remoteModule = await input.instance.loadRemote(input.moduleId); // The loader is interface-agnostic: it only guarantees a module came back. // Render-vs-worker shape validation is the consumer's (a panel/app island // checks `render`; a service checks `url`). if (remoteModule == null) { throw new ModuleLoadError(input.moduleId); } return remoteModule; }); /** * Loads and tracks a single federated module — an application's `App` entry, * one panel view component, or a worker service's loader. * * The deep bit behind a tiny interface: given `{ moduleId, instance }` it does * the federation load, normalises failure, and exposes the whole lifecycle as * three tags — `loading` → `ready` | `failed` — with the loaded `module` (or * `error`) in context. Interface-agnostic: it never inspects the module's * shape, so the same loader serves a render island and a worker alike, leaving * the render-vs-worker contract to the consumer. * * Spawned by an app's `remoteLogic`, one per expose (`${appId}/${moduleId}`) * — its panels warmed upfront, its `App` view on demand. State names are * internal; the tags are the read interface. * * Sends a `remote.settled` event to its parent on entry to either terminal * state, reserved for future supervision/retry. * * @internal */ export const moduleLoaderLogic = setup({ types: { input: {} as ModuleLoaderInput, context: {} as ModuleLoaderContext, tags: {} as "loading" | "ready" | "failed", }, actors: { load: loadLogic, }, actions: { setModule: assign({ module: (_, params: { module: unknown }) => params.module, error: () => null, }), setError: assign({ module: () => null, error: (_, params: { error: unknown }) => normaliseError(params.error), }), }, }).createMachine({ id: "remote", initial: "loading", context: ({ input }) => ({ ...input, module: null, error: null, }), states: { loading: { tags: ["loading"], invoke: { src: "load", input: ({ context }) => ({ moduleId: context.moduleId, instance: context.instance, }), onDone: { target: "loaded", actions: [ { type: "setModule", params: ({ event }) => ({ module: event.output }), }, ], }, onError: { target: "error", actions: [ { type: "setError", params: ({ event }) => ({ error: event.error }), }, ], }, }, }, loaded: { tags: ["ready"], entry: sendParent(({ context }) => ({ type: "remote.settled" as const, moduleId: context.moduleId, status: "loaded" as const, })), }, error: { tags: ["failed"], entry: sendParent(({ context }) => ({ type: "remote.settled" as const, moduleId: context.moduleId, status: "error" as const, })), }, }, }); function normaliseError(error: unknown): RemoteError { if (error instanceof Error) { return { message: error.message, cause: error }; } return { message: String(error), cause: error }; }