import { createSanityInstance, type SanityInstance } from "@sanity/sdk"; import { defer, EMPTY, filter, from, map, of, type Observable } from "rxjs"; import { type ActorRefFrom, type AnyActorRef, assign, raise, sendTo, setup, type ActorOptions, } from "xstate"; import type { ApplicationList } from "../core/applications/application-list"; import type { LocalApplication } from "../core/applications/applications"; import type { LocalAppConfig } from "../core/installations/config"; import { logger } from "../core/log"; import { brandOrganizationId } from "../core/organizations"; // Aliased because this module exports the OS machine as `os`. import { os as bus } from "../runtime/bus"; import { applicationsLogic } from "./applications/machine"; import { authLogic } from "./auth.machine"; import { inspect } from "./inspect"; import { createInstance, type FederationInstance, log, } from "./module-federation"; import { type NavigationAdapter, navigationLogic } from "./navigation/machine"; import { organizationLogic } from "./organization.machine"; import { panelsLogic } from "./panels.machine"; import { projectsLogic } from "./projects.machine"; import { remotesLogic } from "./remotes.machine"; import { type SystemPreferencesAdapter, systemPreferencesLogic, } from "./system-preferences.machine"; import { telemetryLogic, type WorkbenchUserProperties, } from "./telemetry.machine"; // Resolved at subscribe time, so this works whatever order the root invokes its // children in. `null` reports a list that failed, which a child treats as no // list rather than suspending on it. const applicationsFeed = ( self: AnyActorRef, ): Observable => defer(() => from( self.system.get("applications") as ActorRefFrom, ).pipe( filter( ({ context }) => context.applications !== undefined || context.listError !== undefined, ), map(({ context }) => context.applications ?? null), ), ); type OSInput = WorkbenchUserProperties & { systemPreferencesAdapter?: SystemPreferencesAdapter; /** * How the OS reaches the router. Absent in a headless host, where a * navigation request has nowhere to go. */ navigationAdapter?: NavigationAdapter; /** * Apps served by attached dev servers. A stream, so the applications machine * can subscribe directly and a dev server coming or going rebuilds the list * without a host re-render. */ localApplications?: Observable; appConfigs?: Observable; }; type OSContext = { instance: SanityInstance; /** * The organization id the resource queries key on. Seeded from host config so * it is always a string, then replaced with the id from the organization * response once it resolves. */ organizationId: string; /** * The single module-federation instance every interface loads through. Owned * by the `remotes` supervisor's per-app children, so an app's remote is * registered once for all its interfaces (views, panels, workers). */ federationInstance: FederationInstance; userProperties: WorkbenchUserProperties; systemPreferencesAdapter: SystemPreferencesAdapter | undefined; navigationAdapter: NavigationAdapter; localApplications: Observable; appConfigs: Observable; }; /** * The base inputs for the OS machine. * @internal */ export interface OSBaseInput extends Pick {} /** * The sanity OS machine, responsible for managing the global state of the OS. * @public * @example * ```ts * import { os, createOSOptions } from "@sanity/workbench/system"; * import { useActor } from "@xstate/react"; * * const [state, send] = useActor(os, createOSOptions({ * version: "1.0.0", * organizationId: "...", * environment: "production", * userAgent: navigator.userAgent, * })); * ``` */ export const os = setup({ types: { input: {} as OSInput, context: {} as OSContext, events: {} as | { type: "boot.auth.ready" } | { type: "boot.auth.failed" } | { type: "boot.organization.ready" } | { type: "boot.organization.failed" }, // https://github.com/statelyai/xstate/issues/5515 children: {} as { applications: "applications"; auth: "auth"; navigation: "navigation"; organization: "organization"; panels: "panels"; projects: "projects"; telemetry: "telemetry"; "system-preferences": "systemPreferences"; remotes: "remotes"; }, }, actors: { applications: applicationsLogic, auth: authLogic, navigation: navigationLogic, organization: organizationLogic, panels: panelsLogic, projects: projectsLogic, telemetry: telemetryLogic, systemPreferences: systemPreferencesLogic, remotes: remotesLogic, }, guards: { hasTag: (_, params: { hasTag: boolean }) => params.hasTag, }, actions: { raiseAuthReady: raise({ type: "boot.auth.ready" }), raiseAuthFailed: raise({ type: "boot.auth.failed" }), raiseOrganizationReady: raise({ type: "boot.organization.ready" }), storeResolvedOrganizationId: assign({ organizationId: ({ context }, params: { id: string | undefined }) => params.id ?? context.organizationId, }), raiseOrganizationFailed: raise({ type: "boot.organization.failed" }), startTelemetry: sendTo("telemetry", { type: "telemetry.start", }), clearSession: () => { bus.emit("organizations.current", null); bus.emit("applications.foreground", null); bus.emit("applications.list", null); bus.emit("media-libraries.config", null); bus.emit("navigation.location", null); bus.emit("panels.mode", null); }, }, }).createMachine({ id: "os", context: ({ input }) => ({ instance: createSanityInstance(), organizationId: input.organizationId, federationInstance: createInstance({ name: "workbench-applications", plugins: [log(logger.debug)], }), userProperties: { version: input.version, organizationId: input.organizationId, environment: input.environment, userAgent: input.userAgent, }, systemPreferencesAdapter: input.systemPreferencesAdapter, navigationAdapter: input.navigationAdapter ?? { navigate: (href) => { logger.warn("Ignored a navigation request: the host has no router.", { href, }); return Promise.reject(new Error("The host has no router.")); }, location: EMPTY, }, // of([]), not EMPTY: combineLatest in applicationStore$ waits for every // source's first emission, and EMPTY completes without emitting. localApplications: input.localApplications ?? of([]), appConfigs: input.appConfigs ?? of([]), }), initial: "booting", invoke: [ { id: "auth", systemId: "auth", src: "auth", input: ({ context }) => ({ instance: context.instance }), onSnapshot: [ // LoginRedirect hard-navigates on LOGGED_OUT, disposing this actor, so // the OS intentionally has no signed-out transition of its own. { guard: { type: "hasTag", params: ({ event }) => ({ hasTag: event.snapshot.hasTag("authenticated"), }), }, actions: [{ type: "raiseAuthReady" }], }, { guard: { type: "hasTag", params: ({ event }) => ({ hasTag: event.snapshot.hasTag("error"), }), }, actions: [{ type: "raiseAuthFailed" }], }, ], }, { id: "system-preferences", systemId: "system-preferences", src: "systemPreferences", input: ({ context }) => ({ adapter: context.systemPreferencesAdapter }), }, { id: "remotes", systemId: "remotes", src: "remotes", input: ({ context }) => ({ instance: context.federationInstance }), }, ], states: { booting: { on: { "boot.auth.ready": { target: "running" }, "boot.auth.failed": { target: "error" }, }, }, // Authentication makes the shell renderable. The organization resolves // while the shell renders its skeletons; the resource queries wait for it. running: { exit: [{ type: "clearSession" }], invoke: [ { id: "organization", systemId: "organization", src: "organization", input: ({ context }) => ({ instance: context.instance, organizationId: context.userProperties.organizationId, }), onSnapshot: [ { guard: { type: "hasTag", params: ({ event }) => ({ hasTag: event.snapshot.hasTag("organization-resolved"), }), }, actions: [ // Assigns before `ready` is entered — the raised event is // processed after these actions — so the resource queries read // the resolved id, not the host seed. { type: "storeResolvedOrganizationId", params: ({ event }) => ({ id: event.snapshot.context.currentOrganization?.id, }), }, { type: "raiseOrganizationReady" }, ], }, { guard: { type: "hasTag", params: ({ event }) => ({ hasTag: event.snapshot.hasTag("error"), }), }, actions: [{ type: "raiseOrganizationFailed" }], }, ], }, { id: "telemetry", systemId: "telemetry", src: "telemetry", input: ({ context }) => ({ instance: context.instance, ...context.userProperties, }), }, ], on: { // Failure never reaches `ready`, so the resource queries never fire for // an organization that can't be resolved. "boot.organization.failed": { target: "error" }, }, initial: "pending", states: { // The organization is still resolving. Only it and telemetry are up, so // nothing has queried a resource keyed on an organization we don't have. pending: { on: { "boot.organization.ready": { target: "ready", actions: [{ type: "startTelemetry" }], }, }, }, // The organization resolved. The resource queries can run now that there // is an organization to key them on. `panels` and `navigation` join here // because they read the applications actor, which only exists in `ready`. ready: { invoke: [ { id: "projects", systemId: "projects", src: "projects", input: ({ context }) => ({ instance: context.instance, organizationId: brandOrganizationId(context.organizationId), }), }, { id: "applications", systemId: "applications", src: "applications", input: ({ context, self }) => ({ instance: context.instance, // Resolved by the organization machine; the stores key on the brand. organizationId: brandOrganizationId(context.organizationId), localApplications: context.localApplications, appConfigs: context.appConfigs, location: context.navigationAdapter.location, // Resolved at subscribe time, so this works whatever order the // root invokes its children in. projects: defer(() => from( self.system.get("projects") as ActorRefFrom< typeof projectsLogic >, ).pipe( map((snapshot) => snapshot.context.projects), filter((list) => list !== undefined), ), ), }), }, { id: "panels", systemId: "panels", src: "panels", input: ({ self }) => ({ applications: applicationsFeed(self) }), }, { id: "navigation", systemId: "navigation", src: "navigation", input: ({ context, self }) => ({ // Wrapped, not passed by reference: the adapter is public API, so // a host may implement `navigate` as a method that needs its // receiver. navigate: (href, options) => context.navigationAdapter.navigate(href, options), location: context.navigationAdapter.location, applications: applicationsFeed(self), }), }, ], }, }, }, error: {}, }, }); /** * Creates a set of default options for the OS machine. Forwards the workbench * user properties to the machine's input and wires the structured-logging * `inspect` callback. Browser-specific concerns (color-scheme seeding, * persistence) live in the {@link SystemPreferencesAdapter} the host passes * via `systemPreferencesAdapter`. * @public */ export function createOSOptions(input: OSInput) { return { id: "os", input, inspect, } satisfies ActorOptions; }