import path from "node:path"; import { type ModuleFederationOptions } from "@module-federation/vite"; import type { PluginOption } from "vite"; import { artifactExposes } from "../interfaces/artifact"; import { serviceArtifacts, type ServiceArtifact, } from "../interfaces/services/artifact"; import { viewArtifacts, type InterfaceArtifact, } from "../interfaces/views/artifact"; import { FEDERATION_FILE_NAME, RUNTIME_DIR } from "./constants"; import { pluginModuleFederation, type FederationOptions, } from "./plugins/plugin-module-federation"; import { sanityEnvironmentPlugin } from "./plugins/plugin-sanity-environment"; import { sanityExtensionArtifacts } from "./plugins/plugin-sanity-extension-artifacts"; import { sanityFederationRuntime, type FederationRuntimeOptions, } from "./plugins/plugin-sanity-federation-runtime"; import { invariant } from "./utils/invariant"; import { type PackageJson } from "./utils/packageJson"; interface FederationPluginOptionsBase extends Omit< Partial, "exposes" > { exposes?: Record; /** * Views the app declares. Each component of a view is built into a * render-contract artifact and exposed as `./views//` so the * workbench host can load each as its own island. */ views?: readonly InterfaceArtifact[]; /** * Background services the app declares. Each is built into a self-contained * worker bundle plus a loader module exposed as `./services/`, so the * workbench host can fetch the worker URL and run it. */ services?: readonly ServiceArtifact[]; pkgJson?: PackageJson; /** * Current working directory to read package.json from, defaults to process.cwd() */ workDir?: string; } interface AppFederationPluginOptions extends FederationPluginOptionsBase { isApp: true; /** * Relative path to the App entry from the runtime directory, e.g. * `../../src/App.tsx`. Omit it for a dock-only app (US5) that declares no * `entry`: no `./App` is exposed and the remote serves only its views. */ appEntry?: string; studioConfigPath?: never; } interface StudioFederationPluginOptions extends FederationPluginOptionsBase { /** @defaultValue false */ isApp?: false; /** relative path to the Studio config file from the runtime directory (e.g. `../../sanity.config.ts`). */ studioConfigPath: string; appEntry?: never; } /** * Plugin options for `@sanity/federation/vite`. * * Discriminated on `isApp`: * - `isApp: true` → requires `appEntry` * - `isApp: false` (default) → requires `studioConfigPath` * * @public */ export type FederationPluginOptions = | AppFederationPluginOptions | StudioFederationPluginOptions; /** * @public */ export const federation = (options: FederationPluginOptions): PluginOption => { const { workDir = process.cwd(), exposes: defaultExposes = {}, views = [], services = [], name: defaultName, pkgJson, } = options; let name = defaultName; if (!name) { name = pkgJson?.name; } invariant( name, '"name" option is required but could not be inferred from package.json', ); const generatedEntry = `./${RUNTIME_DIR}/${FEDERATION_FILE_NAME}.jsx`; function resolveEntryPath(entry: string) { const resolvedPath = path.resolve(workDir, entry); invariant( resolvedPath, `Could not resolve path for entry "${entry}". Resolved to "${resolvedPath}". Please check that the file exists and the path is correct.`, ); return resolvedPath; } const entryPath = resolveEntryPath(generatedEntry); const resolvedExposes = Object.entries(defaultExposes).reduce( (acc, [key, exposePath]) => { acc[key] = resolveEntryPath(exposePath) ?? exposePath; return acc; }, {} as Record, ); // Each view component (`./views//`) and each service loader // (`./services/`) is exposed straight to the host, pointing at the file // the extension-artifacts plugin generates under RUNTIME_DIR. A service's // worker bundle carries no expose — the host reaches it through its loader. const interfaceExposes = artifactExposes( [...viewArtifacts(views), ...serviceArtifacts(services)], (artifactPath) => resolveEntryPath(`./${RUNTIME_DIR}/${artifactPath}`), ); // A dock-only app (US5: `isApp` with no `appEntry`) has no navigable // full-page view, so it exposes no `./App` — only its views. Studios and apps // with an entry expose `./App` (the generated render entry). const exposesApp = !options.isApp || options.appEntry !== undefined; const exposes: NonNullable = { ...(exposesApp ? { "./App": entryPath } : {}), ...resolvedExposes, ...interfaceExposes, }; const runtimeOptions: FederationRuntimeOptions = options.isApp ? { isApp: true, appEntry: options.appEntry } : { isApp: false, studioConfigPath: options.studioConfigPath }; return [ sanityEnvironmentPlugin({ input: entryPath }), sanityFederationRuntime(runtimeOptions), sanityExtensionArtifacts({ views, services }), pluginModuleFederation({ name, exposes }), ]; }; export type { FederationOptions };