import { assign, fromCallback, setup } from "xstate"; import { logger } from "../core/log"; import { type FederationInstance } from "./module-federation"; import { moduleLoaderLogic, type RemoteError, type ServiceLoaderModule, } from "./module-loader.machine"; /** * @internal */ export interface ServiceInput { /** Stable child key, `${appId}:${serviceName}`. */ key: string; appId: string; serviceName: string; entry: string; instance: FederationInstance; } type ServiceContext = ServiceInput & { /** Resolved worker-bundle URL, absolute on the app origin. */ workerUrl: string | null; }; /** Federation expose segment a service's loader lives under: `services/`. */ const SERVICES_MODULE = "services"; /** * Run the worker. `new Worker(appOriginUrl)` is cross-origin-blocked, and the * dev worker isn't self-contained (its root-relative imports would resolve * against the wrong origin from a blob). So bootstrap a same-origin worker that * dynamically `import()`s the app-origin URL: the worker module then resolves * its own imports against the app origin, in dev and build alike. Crashes, a * failed load, and the worker's `console.*` (bridged by the wrapper) are * surfaced through the host logger (the host owns logging; a worker's own * console isn't visible in the page DevTools anyway). Cleanup disposes, then * terminates. */ const runWorker = fromCallback< { type: string }, { key: string; serviceName: string; workerUrl: string } >(({ input }) => { let worker: Worker | undefined; let blobUrl: string | undefined; try { // A same-origin module worker whose only job is to load the real worker // from the app origin, so that worker's imports resolve there too. const bootstrap = `import(${JSON.stringify(input.workerUrl)})`; blobUrl = URL.createObjectURL( new Blob([bootstrap], { type: "text/javascript" }), ); worker = new Worker(blobUrl, { type: "module" }); worker.addEventListener("message", (event: MessageEvent) => { if (event.data?.kind === "workbench.worker.error") { logger.error( `Service "${input.key}" worker error`, event.data.payload?.message, ); } else if (event.data?.kind === "workbench.worker.log") { // Re-emit the worker's `console.*` (bridged by the wrapper) through the // host logger, prefixed with the service name so it shows in the page // console. const { level, message } = event.data.payload ?? {}; const emit: Record void> = { warn: logger.warn, error: logger.error, debug: logger.debug, }; (emit[level] ?? logger.info)( `[service:${input.serviceName}] ${message ?? ""}`, ); } }); // A module-load failure (or any uncaught worker error) fires here on the // host side — surface it instead of failing silently. worker.addEventListener("error", (event: ErrorEvent) => { logger.error( `Service "${input.key}" worker error: ${event.message || String(event)}`, ); }); } catch (error) { logger.error(`Service "${input.key}" failed to start its worker`, error); } return () => { try { worker?.postMessage({ kind: "workbench.worker.terminate" }); } finally { worker?.terminate(); if (blobUrl) URL.revokeObjectURL(blobUrl); } }; }); /** * Lifecycle machine for a single background service worker. * * Spawned by an app's `remoteLogic`, one per `(app, service)`, so a worker * runs whenever its app is available and is disposed when the app leaves. * `loading` loads the worker's loader module through the shared * {@link moduleLoaderLogic} lifecycle — the same machine every interface (panel, * app, worker) loads through — and reads the bundle URL off it. A worker isn't a * render module, so it's never handed to an island; `running` bootstraps it as * a Web Worker from that URL. * * @internal */ export const serviceLogic = setup({ types: { input: {} as ServiceInput, context: {} as ServiceContext, tags: {} as "loading" | "running" | "failed", }, actors: { loadInterface: moduleLoaderLogic, runWorker, }, }).createMachine({ id: "service", initial: "loading", context: ({ input }) => ({ ...input, workerUrl: null }), // The invoked loader `sendParent`s a `remote.settled` on settle; this machine // reacts via `onSnapshot` instead, so the event is a no-op here. on: { "remote.settled": {} }, states: { loading: { tags: ["loading"], invoke: { id: "loader", src: "loadInterface", input: ({ context }) => ({ moduleId: `${context.appId}/${SERVICES_MODULE}/${context.serviceName}`, instance: context.instance, }), onSnapshot: [ { // Loaded and exposing a bundle URL → run it. guard: ({ event }) => { const module = event.snapshot.context .module as ServiceLoaderModule | null; return ( event.snapshot.hasTag("ready") && typeof module?.url === "string" ); }, target: "running", actions: assign({ workerUrl: ({ context, event }) => { const module = event.snapshot.context .module as ServiceLoaderModule; // The URL is root-relative to the app; resolve it absolute // against the app origin since the loader runs in the host page. return new URL(module.url, context.entry).href; }, }), }, { // Load failed, or loaded without a usable URL → no worker. guard: ({ event }) => { const module = event.snapshot.context .module as ServiceLoaderModule | null; return ( event.snapshot.hasTag("failed") || (event.snapshot.hasTag("ready") && typeof module?.url !== "string") ); }, target: "error", // A worker has no UI surface (unlike a view's error boundary), so a // failed load would otherwise be silent — log it through the host. actions: ({ context, event }) => { const cause = (event.snapshot.context.error as RemoteError | null) ?.message; logger.error( `Service "${context.key}" failed to load its worker${ cause ? `: ${cause}` : "" }`, ); }, }, ], }, }, running: { tags: ["running"], invoke: { src: "runWorker", input: ({ context }) => ({ key: context.key, serviceName: context.serviceName, // `workerUrl` is set on entry to `running` from `loading`'s output. workerUrl: context.workerUrl!, }), }, }, error: { tags: ["failed"], }, }, });