import { z } from "zod"; import { getInitials } from "../shared/avatar"; import type { LocalCoreApplicationManifest } from "./core-apps"; import { type DockGroup, type Interface, type InterfaceOfType, type InterfacesOf, InterfaceSchema, type LocalInterface, LocalInterfaceSchema, type Panel, type PanelComponent, type TileSize, } from "./interfaces"; import type { LocalStudioApplicationManifest } from "./studios/schemas"; export { type DockGroup, type Interface, type InterfaceOfType, type InterfacesOf, InterfaceSchema, type LocalInterface, LocalInterfaceSchema, type Panel, type PanelComponent, type TileSize, }; const LocalApplicationBase = z.object({ host: z.string(), port: z.number(), /** * Interfaces the app exposes — dock panels (`type: "panel"`) and * background workers (`type: "worker"`), forwarded from the dev * server in one list. */ interfaces: z.array(LocalInterfaceSchema).optional(), /** * The application id the CLI registered this dev server under. A deployed * application of the same id is paired with it as its `remoteApplication`. */ id: z.string().nonempty(), /** * Stable identity and its qualified reference, composed by the CLI (the * authority for local apps, which never reach brett) and read straight here. */ name: z.string(), reference: z.string(), /** * The `api.projectId` from the application's `sanity.cli.ts`. Available * synchronously at dev-server startup (no manifest extraction required), so * the studio's primary project is resolvable from the very first local * application event. */ projectId: z.string().optional(), }); /** * Raw data for a local application discovered by the CLI dev server. * * The CLI forwards the full studio or app manifest so the workbench can * derive icons, titles, workspaces and schema references the same way it * does for deployed applications. The manifest shape is discriminated on * `type`: studios receive a `LocalStudioApplicationManifest`, core apps a `LocalCoreApplicationManifest`. * * When set on an application, `isLocal` is `true` and `url`/`isFederated` * honour the local dev-server instead of the deployed application's host. * * @public */ export const LocalApplication = z.discriminatedUnion("type", [ LocalApplicationBase.extend({ type: z.literal("studio"), manifest: z.custom().optional(), }), LocalApplicationBase.extend({ type: z.literal("coreApp"), manifest: z.custom().optional(), }), ]); /** * @public */ export type LocalApplication = z.output; /** * @public */ export type AbstractApplicationType = | "studio" | "coreApp" | "canvas" | "media-library" | "workspace"; /** * A description a renderer turns into markup, so drawing an application's icon * needs no dispatch on `app.type` and this stays framework-agnostic enough to * live in `core`. Callers add the dev overlay from `isLocal`. * @public */ export type IconDescriptor = | { variant: "image"; svg: string } | { variant: "avatar"; initials: string; color: ReturnType["color"]; }; /** * One loadable expose of an interface, flattened with everything a consumer * needs to load it into its own module-federation instance (`registerRemotes` * then `loadRemote`) — self-contained and clone-safe. A discriminated union on * `type`, mirroring {@link Interface}: narrow to a `tile` (or `app`) and its * `metadata` comes with it, while the metadata-less types never carry the field. * A multi-component view (a panel) yields one of these per component. Produced * by {@link AbstractApplication.resolvedInterfaces}. * @public */ export type ResolvedInterface = { [TType in Interface["type"]]: Readonly< // The record's identity, minus what only the deployment owns (`id`, // `version`) or the resolved ref replaces (`moduleId`) — so each variant // keeps its `metadata` where the interface has any. Omit, "id" | "version" | "moduleId"> > & { /** The owning app — the remote name to `registerRemotes` under. */ readonly appId: string; /** The remote entry to `registerRemotes` with — the owning app's origin. */ readonly entry: string; /** * The module id a consumer `loadRemote`s — `${appId}/${moduleId}`, with a * `/${component}` slot appended for a view island. */ readonly moduleId: string; }; }[Interface["type"]]; /** The resolved ref for a given interface `type`. @public */ export type ResolvedInterfaceOfType = Extract< ResolvedInterface, { type: T } >; /** * What `resolvedInterfaces(type?)` returns: narrowed to `type` (so a `tile`'s * `metadata` is in hand without a second lookup), or every resolved ref when * `type` is omitted — the {@link InterfacesOf} mirror for resolved refs. * @public */ export type ResolvedInterfacesOf = readonly (T extends Interface["type"] ? ResolvedInterfaceOfType : ResolvedInterface)[]; /** * The component slots each view interface type exposes, in render order — the * workbench's mirror of the CLI's `VIEW_COMPONENTS` (the federation contract * locked in SDK-2188). A view exposes one federation island per slot; a type * absent here (an `app` view, a `worker` service) exposes a single module with * no slot. */ const VIEW_COMPONENTS: Partial> = { asset_source: ["asset_source"], panel: ["title", "panel"], tile: ["tile"], }; /** * @public */ export abstract class AbstractApplication< TType extends AbstractApplicationType, > { readonly type: TType; constructor(type: TType) { this.type = type; } /** * The in-app route this application navigates to, or `null` when it isn't * navigable as a full-page app (US5, spec 002-workbench-extension-api — e.g. an SDK app with no `app` view). * The dock renders a navigable item only for a non-null `href`; the app * routes 404 a `null`-href app on direct visit. */ abstract get href(): string | null; abstract get title(): string; abstract get id(): string; /** Hashed off the id, so the fallback hue is stable across sessions. */ get avatarColor(): ReturnType["color"] { return getInitials({ id: this.id, name: this.title }).color; } /** * Whether the application is federated or not. This is used to determine * if the application should be rendered in an iframe or not. */ abstract get isFederated(): boolean; /** * Whether the application is served by a local CLI dev server rather than a * deployed remote. Defaults to `false`; applications flip this when * constructed from a `LocalApplication` payload. */ get isLocal(): boolean { return false; } abstract get url(): URL; /** * Interfaces the application exposes (e.g. dock panels) — all of them, or * only those of the given `type`. Defaults to none; applications * surface what they declared via `unstable_defineApp`. */ interfaces( _type?: T, ): InterfacesOf { return []; } /** * Whether the application has a navigable full-page `app` view (US5, spec 002-workbench-extension-api). Defaults * to `true` — studios, Canvas, and Media Library are always navigable. An SDK * app overrides this to derive it from whether it declares an `app` interface. */ get hasAppView(): boolean { return true; } /** * Federation module id of the app's full-page view, or `null` when it can't * be federation-loaded: the app isn't federated (Canvas, Media Library, * deployed apps shown in an iframe) or has no app view. Only a non-null * `moduleId` is fetched and rendered by the remotes machine. */ get moduleId(): string | null { return this.isFederated && this.hasAppView ? `${this.id}/App` : null; } /** * Federation module id of one of this app's panel view components, or `null` * when the app isn't federated (a non-federated app's panels can't be loaded * as remotes). */ resolveViewModuleId(view: Panel, component: PanelComponent): string | null { return this.isFederated ? `${this.id}/views/${view.name}/${component}` : null; } /** * Every interface this app exposes, each flattened into a self-contained, * loadable {@link ResolvedInterface} — the app's remote `entry`, the * `moduleId` a consumer `loadRemote`s, and the interface's identity — or only * those of the given `type`. A view yields one ref per component slot; an * `app` view or `worker` service yields a single ref. Empty when the app * can't be federation-loaded (an iframe app). A consumer loads each into its * own federation instance the way the workbench loads its own remotes. */ resolvedInterfaces( type?: T, ): ResolvedInterfacesOf { // The one skip is app-level, so gate once here and the per-expose build is // a plain map — every interface then resolves. if (!this.isFederated) return [] as unknown as ResolvedInterfacesOf; // The runtime narrows correctly; TS can't tie the mapped result back to the // conditional return type, so assert it (as with `interfaces`). return this.interfaces(type).flatMap((iface) => { // A view exposes one island per component; everything else is one module. const components = VIEW_COMPONENTS[iface.type] ?? [undefined]; // Strip the record-only fields; `identity` keeps type/name/title — and // `metadata`, for the interfaces that carry it — so it rides every ref. const { id: _id, version: _version, moduleId, ...identity } = iface; return components.map((component) => ({ ...identity, appId: this.id, entry: this.url.origin, moduleId: component === undefined ? `${this.id}/${moduleId}` : `${this.id}/${moduleId}/${component}`, })); }) as unknown as ResolvedInterfacesOf; } get initials(): string { return getInitials({ id: this.id, name: this.title }).initials; } }