diff --git a/.changeset/pr-1149.md b/.changeset/pr-1149.md new file mode 100644 index 000000000..b41869af1 --- /dev/null +++ b/.changeset/pr-1149.md @@ -0,0 +1,6 @@ + +--- +'@sanity/cli': minor +--- + +feat(workbench): build app view artifacts and register them on deploy \ No newline at end of file diff --git a/package.json b/package.json index fcf0c3ea1..83d661367 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,8 @@ "node-pty" ], "overrides": { - "@sanity/cli": "workspace:*" + "@sanity/cli": "workspace:*", + "@sanity/federation": "https://pkg.pr.new/sanity-io/workbench/@sanity/federation@1a9ececc8238fb63e5a8f7bd21b091c2067c2091" } } } diff --git a/packages/@sanity/cli/package.json b/packages/@sanity/cli/package.json index 268ef7254..7efa6e041 100644 --- a/packages/@sanity/cli/package.json +++ b/packages/@sanity/cli/package.json @@ -45,6 +45,10 @@ "source": "./src/exports/_internal.ts", "default": "./dist/exports/_internal.js" }, + "./runtime": { + "source": "./src/exports/runtime.ts", + "default": "./dist/exports/runtime.js" + }, "./package.json": "./package.json" }, "publishConfig": { diff --git a/packages/@sanity/cli/src/actions/build/buildApp.ts b/packages/@sanity/cli/src/actions/build/buildApp.ts index 11d721db1..bd52db69b 100644 --- a/packages/@sanity/cli/src/actions/build/buildApp.ts +++ b/packages/@sanity/cli/src/actions/build/buildApp.ts @@ -13,7 +13,7 @@ import { UserViteConfig, } from '@sanity/cli-core' import {confirm, logSymbols, spinner, type SpinnerInstance} from '@sanity/cli-core/ux' -import {isWorkbenchApp} from '@sanity/federation' +import {type DefineAppInput, isWorkbenchApp} from '@sanity/federation' import {parse as semverParse} from 'semver' import {AppBuildTrace} from '../../telemetry/build.telemetry.js' @@ -45,6 +45,7 @@ interface InternalBuildOptions { sourceMap: boolean stats: boolean unattendedMode: boolean + views: DefineAppInput['views'] vite: UserViteConfig | undefined workDir: string } @@ -57,14 +58,19 @@ interface InternalBuildOptions { export async function buildApp(options: BuildOptions): Promise { const {cliConfig, flags, outDir, output, workDir} = options + const app = cliConfig && 'app' in cliConfig ? cliConfig.app : undefined + // `views` lives on `unstable_defineApp`'s result, not the legacy `app` config + // object — read it off the branded app. + const workbenchApp = isWorkbenchApp(app) ? app : undefined + await internalBuildApp({ appId: getAppId(cliConfig), - appTitle: cliConfig && 'app' in cliConfig ? cliConfig.app?.title : undefined, + appTitle: app?.title, autoUpdatesEnabled: options.autoUpdatesEnabled, calledFromDeploy: options.calledFromDeploy, determineBasePath: () => determineBasePath(cliConfig, 'app', output), - entry: cliConfig && 'app' in cliConfig ? cliConfig.app?.entry : undefined, - isWorkbench: isWorkbenchApp(cliConfig && 'app' in cliConfig ? cliConfig.app : undefined), + entry: app?.entry, + isWorkbench: Boolean(workbenchApp), minify: flags.minify, outDir, output, @@ -73,6 +79,7 @@ export async function buildApp(options: BuildOptions): Promise { sourceMap: Boolean(flags['source-maps']), stats: flags.stats, unattendedMode: flags.yes, + views: workbenchApp?.views, vite: cliConfig.vite, workDir, }) @@ -233,6 +240,7 @@ async function internalBuildApp(options: InternalBuildOptions): Promise { reactCompiler: options.reactCompiler, schemaExtraction: options.schemaExtraction, sourceMap: options.sourceMap, + views: options.views, vite: options.vite, }) diff --git a/packages/@sanity/cli/src/actions/build/buildStaticFiles.ts b/packages/@sanity/cli/src/actions/build/buildStaticFiles.ts index 9c247c491..04e69fcb7 100644 --- a/packages/@sanity/cli/src/actions/build/buildStaticFiles.ts +++ b/packages/@sanity/cli/src/actions/build/buildStaticFiles.ts @@ -2,6 +2,7 @@ import path from 'node:path' import {buildDebug, copyDir, writeFavicons} from '@sanity/cli-build/_internal' import {type CliConfig, type UserViteConfig} from '@sanity/cli-core' +import {type ViewArtifact} from '@sanity/federation/vite' import {type PluginOptions as ReactCompilerConfig} from 'babel-plugin-react-compiler' import {build, createBuilder} from 'vite' @@ -36,6 +37,7 @@ interface StaticBuildOptions { reactCompiler?: ReactCompilerConfig schemaExtraction?: CliConfig['schemaExtraction'] sourceMap?: boolean + views?: readonly ViewArtifact[] vite?: UserViteConfig } @@ -61,6 +63,7 @@ export async function buildStaticFiles( reactCompiler, schemaExtraction, sourceMap = false, + views, vite: extendViteConfig, } = options @@ -86,6 +89,7 @@ export async function buildStaticFiles( outputDir, reactCompiler, sourceMap, + views, }) // Apply the user's Vite config so plugins like `@vanilla-extract/vite-plugin` diff --git a/packages/@sanity/cli/src/actions/build/buildStudio.ts b/packages/@sanity/cli/src/actions/build/buildStudio.ts index 8369dae7e..0792d25df 100644 --- a/packages/@sanity/cli/src/actions/build/buildStudio.ts +++ b/packages/@sanity/cli/src/actions/build/buildStudio.ts @@ -13,7 +13,7 @@ import { UserViteConfig, } from '@sanity/cli-core' import {confirm, logSymbols, select, spinner, type SpinnerInstance} from '@sanity/cli-core/ux' -import {isWorkbenchApp} from '@sanity/federation' +import {type DefineAppInput, isWorkbenchApp} from '@sanity/federation' import {parse as semverParse} from 'semver' import {StudioBuildTrace} from '../../telemetry/build.telemetry.js' @@ -50,6 +50,7 @@ interface InternalBuildOptions { stats: boolean unattendedMode: boolean upgradePackages(options: {packages: [name: string, version: string][]}): Promise + views: DefineAppInput['views'] vite: UserViteConfig | undefined workDir: string } @@ -62,6 +63,11 @@ interface InternalBuildOptions { export async function buildStudio(options: BuildOptions): Promise { const {calledFromDeploy, cliConfig, flags, outDir, output, workDir} = options + // `views` lives on the branded `unstable_defineApp` result — read it off the + // branded app so it's gated on the brand, like the app build. + const app = cliConfig?.app + const workbenchApp = isWorkbenchApp(app) ? app : undefined + const upgradePkgs = async (options: { packages: [name: string, version: string][] }): Promise => { @@ -80,7 +86,7 @@ export async function buildStudio(options: BuildOptions): Promise { calledFromDeploy, determineBasePath: () => determineBasePath(cliConfig, 'studio', output), isApp: determineIsApp(cliConfig), - isWorkbench: isWorkbenchApp(cliConfig?.app), + isWorkbench: Boolean(workbenchApp), minify: Boolean(flags.minify), outDir, output, @@ -91,6 +97,7 @@ export async function buildStudio(options: BuildOptions): Promise { stats: flags.stats, unattendedMode: Boolean(flags.yes), upgradePackages: upgradePkgs, + views: workbenchApp?.views, vite: cliConfig.vite, workDir, }) @@ -118,6 +125,7 @@ async function internalBuildStudio(options: InternalBuildOptions): Promise stats, unattendedMode, upgradePackages, + views, vite, workDir, } = options @@ -300,6 +308,7 @@ async function internalBuildStudio(options: InternalBuildOptions): Promise reactCompiler, schemaExtraction, sourceMap, + views, vite, }) diff --git a/packages/@sanity/cli/src/actions/build/getViteConfig.ts b/packages/@sanity/cli/src/actions/build/getViteConfig.ts index 75c515ac0..66f25aff8 100644 --- a/packages/@sanity/cli/src/actions/build/getViteConfig.ts +++ b/packages/@sanity/cli/src/actions/build/getViteConfig.ts @@ -8,7 +8,7 @@ import { readPackageJson, type UserViteConfig, } from '@sanity/cli-core' -import {federation as viteFederation} from '@sanity/federation/vite' +import {type ViewArtifact, federation as viteFederation} from '@sanity/federation/vite' import viteReact from '@vitejs/plugin-react' import {type PluginOptions as ReactCompilerConfig} from 'babel-plugin-react-compiler' import debug from 'debug' @@ -95,6 +95,12 @@ interface ViteOptions extends Pick { * Whether or not to enable source maps */ sourceMap?: boolean + + /** + * Views the workbench app declares. Built into render-contract artifacts and + * exposed through module federation as `./views/`. + */ + views?: readonly ViewArtifact[] } /** @@ -121,6 +127,7 @@ export async function getViteConfig(options: ViteOptions): Promise // default to `true` when `mode=development` sourceMap = options.mode === 'development', typegen, + views, } = options const basePath = normalizeBasePath(rawBasePath) @@ -218,6 +225,7 @@ export async function getViteConfig(options: ViteOptions): Promise studioConfigPath: entries.relativeConfigLocation!, }), pkgJson: await readPackageJson(path.join(cwd, 'package.json')), + views, workDir: cwd, }), ] diff --git a/packages/@sanity/cli/src/actions/deploy/deployApp.ts b/packages/@sanity/cli/src/actions/deploy/deployApp.ts index c556a034e..fa3685ce4 100644 --- a/packages/@sanity/cli/src/actions/deploy/deployApp.ts +++ b/packages/@sanity/cli/src/actions/deploy/deployApp.ts @@ -5,6 +5,7 @@ import {createGzip} from 'node:zlib' import {CLIError} from '@oclif/core/errors' import {getLocalPackageVersion} from '@sanity/cli-core' import {spinner} from '@sanity/cli-core/ux' +import {isWorkbenchApp} from '@sanity/federation' import {pack} from 'tar-fs' import {createDeployment, updateUserApplication} from '../../services/userApplications.js' @@ -20,6 +21,7 @@ import {createUserApplicationForApp} from './createUserApplicationForApp.js' import {deployDebug} from './deployDebug.js' import {findUserApplicationForApp} from './findUserApplicationForApp.js' import {type DeployAppOptions} from './types.js' +import {buildViewDeploymentPayload} from './viewDeployment.js' /** * Deploy a Sanity application. @@ -137,6 +139,30 @@ export async function deployApp(options: DeployAppOptions) { } } + // Register the app's declared views with the application service. That + // service doesn't exist yet, so validate the payload and log it (no store); + // a malformed view declaration fails the deploy before we ship the bundle. + // `views` lives on the branded `unstable_defineApp` result, not the legacy + // `app` config object. + const declaredViews = isWorkbenchApp(cliConfig.app) ? (cliConfig.app.views ?? []) : [] + if (declaredViews.length > 0) { + try { + const payload = buildViewDeploymentPayload({ + applicationId: userApplication.id, + views: declaredViews, + }) + output.log( + `Validated ${payload.views.length} view(s) for the application service (not yet persisted):`, + ) + output.log(JSON.stringify(payload, null, 2)) + deployDebug('View deployment payload', payload) + } catch (err) { + const message = getErrorMessage(err) + output.error(`Invalid view declaration: ${message}`, {exit: 1}) + return + } + } + spin = spinner('Deploying...').start() await createDeployment({ applicationId: userApplication.id, diff --git a/packages/@sanity/cli/src/actions/deploy/viewDeployment.ts b/packages/@sanity/cli/src/actions/deploy/viewDeployment.ts new file mode 100644 index 000000000..7fbf11588 --- /dev/null +++ b/packages/@sanity/cli/src/actions/deploy/viewDeployment.ts @@ -0,0 +1,41 @@ +import {z} from 'zod' + +/** + * A view record as persisted to the application service: `type`, `name`, `src`, + * plus any view-type-specific attributes (passed through for storage). + */ +const viewRecordSchema = z + .object({ + name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'View `name` must match /^[a-zA-Z0-9_-]+$/'), + src: z.string(), + type: z.enum(['panel']), + }) + .passthrough() + +/** + * Payload registering an app's views with the application service on deploy. + * + * Phase 1 stub: the service that stores views does not exist yet, so the + * payload is validated and logged only — never sent. Builds the contract the + * application-service endpoint will accept. + */ +const viewDeploymentPayloadSchema = z.object({ + applicationId: z.string(), + views: z.array(viewRecordSchema), +}) + +type ViewDeploymentPayload = z.infer + +/** + * Validate an app's declared views into the application-service payload. + * Throws (via Zod) when a view declaration is malformed. + */ +export function buildViewDeploymentPayload(input: { + applicationId: string + views?: ReadonlyArray> +}): ViewDeploymentPayload { + return viewDeploymentPayloadSchema.parse({ + applicationId: input.applicationId, + views: input.views ?? [], + }) +} diff --git a/packages/@sanity/cli/src/actions/dev/devServerRegistry.ts b/packages/@sanity/cli/src/actions/dev/devServerRegistry.ts index 385803842..6939ab810 100644 --- a/packages/@sanity/cli/src/actions/dev/devServerRegistry.ts +++ b/packages/@sanity/cli/src/actions/dev/devServerRegistry.ts @@ -29,6 +29,16 @@ const workbenchLockSchema = z.object({ const devServerManifestSchema = z.extend(workbenchLockSchema, { id: z.optional(z.string()), + /** + * Interfaces the app exposes (e.g. dock panels), mapped from the declared + * views with the local dev server as their `entry_point`. Carried separately + * from the manifest — interfaces live in the application service, not the + * manifest — so the workbench can render local panels without a deploy. + * Lenient by design; the workbench is the authority on the interface shape. + */ + interfaces: z.optional( + z.array(z.object({entry_point: z.string(), interface_type: z.string(), name: z.string()})), + ), /** Inlined manifest — either a {@link StudioManifest} or {@link CoreAppManifest}. */ manifest: z.optional(z.union([studioManifestSchema, coreAppManifestSchema])), /** diff --git a/packages/@sanity/cli/src/actions/dev/getDevServerConfig.ts b/packages/@sanity/cli/src/actions/dev/getDevServerConfig.ts index 3e769d1f7..7276c2074 100644 --- a/packages/@sanity/cli/src/actions/dev/getDevServerConfig.ts +++ b/packages/@sanity/cli/src/actions/dev/getDevServerConfig.ts @@ -36,6 +36,9 @@ export function getDevServerConfig({ const isApp = cliConfig ? determineIsApp(cliConfig) : false const reactStrictMode = resolveReactStrictMode(cliConfig) + // `views` is declared via `unstable_defineApp`, so read it off the branded + // app result rather than the legacy `app` config type. + const app = cliConfig?.app const envBasePath = getSanityEnvVar('BASEPATH', isApp ?? false) if (envBasePath && cliConfig?.project?.basePath) { @@ -46,10 +49,11 @@ export function getDevServerConfig({ return { ...baseConfig, - isWorkbench: isWorkbenchApp(cliConfig?.app), + isWorkbench: isWorkbenchApp(app), reactCompiler: cliConfig && 'reactCompiler' in cliConfig ? cliConfig.reactCompiler : undefined, reactStrictMode, staticPath: path.join(workDir, 'static'), typegen: cliConfig?.typegen, + views: isWorkbenchApp(app) ? app.views : undefined, } } diff --git a/packages/@sanity/cli/src/actions/dev/startFederationRegistration.ts b/packages/@sanity/cli/src/actions/dev/startFederationRegistration.ts index 5057f0202..c256cc772 100644 --- a/packages/@sanity/cli/src/actions/dev/startFederationRegistration.ts +++ b/packages/@sanity/cli/src/actions/dev/startFederationRegistration.ts @@ -1,4 +1,5 @@ import {type CliConfig, type Output} from '@sanity/cli-core' +import {isWorkbenchApp} from '@sanity/federation' import {type ViteDevServer} from 'vite' import {checkForDeprecatedAppId, getAppId} from '../../util/appId.js' @@ -37,9 +38,24 @@ export async function startFederationRegistration( const addr = server.httpServer?.address() const appPort = typeof addr === 'object' && addr ? addr.port : server.config.server.port + // Interfaces live on the branded `unstable_defineApp` result as declared + // views. Map them to the local interface shape — the dev server is the + // `entry_point` the workbench loads each from — and forward them on the + // registry entry (alongside, not inside, the manifest) so the workbench can + // render local panels without a deploy. + const entryPoint = `http://${appHost}:${appPort}/mf-manifest.json` + const interfaces = isWorkbenchApp(cliConfig.app) + ? cliConfig.app.views?.map((view) => ({ + entry_point: entryPoint, + interface_type: view.type, + name: view.name, + })) + : undefined + const registration = registerDevServer({ host: appHost, id: getAppId(cliConfig), + interfaces, port: appPort, projectId: cliConfig?.api?.projectId, type: isApp ? 'coreApp' : 'studio', diff --git a/packages/@sanity/cli/src/actions/dev/startWorkbenchDevServer.ts b/packages/@sanity/cli/src/actions/dev/startWorkbenchDevServer.ts index 381de12e7..addea175c 100644 --- a/packages/@sanity/cli/src/actions/dev/startWorkbenchDevServer.ts +++ b/packages/@sanity/cli/src/actions/dev/startWorkbenchDevServer.ts @@ -21,9 +21,10 @@ import {writeWorkbenchRuntime} from './writeWorkbenchRuntime.js' const noop = async () => {} const toApplicationsPayload = (servers: DevServerManifest[]) => ({ - applications: servers.map(({host, id, manifest, port, projectId, type}) => ({ + applications: servers.map(({host, id, interfaces, manifest, port, projectId, type}) => ({ host, id, + interfaces, manifest, port, projectId, diff --git a/packages/@sanity/cli/src/exports/index.ts b/packages/@sanity/cli/src/exports/index.ts index adc1be882..b60c926d8 100644 --- a/packages/@sanity/cli/src/exports/index.ts +++ b/packages/@sanity/cli/src/exports/index.ts @@ -6,7 +6,10 @@ export {type CliClientOptions, getCliClient} from '../util/cliClient.js' export {loadEnv} from '../util/loadEnv.js' export type {CliConfig, UserViteConfig} from '@sanity/cli-core' -// Workbench application extension API. Canonical implementation in -// `@sanity/federation`; re-exported here so `sanity/cli` can surface it to +// Workbench application extension API (config-time). Canonical implementation +// in `@sanity/federation`; re-exported here so `sanity/cli` can surface it to // app authors via `import {unstable_defineApp} from 'sanity/cli'`. +// The runtime helper `unstable_defineView` is NOT here — it bundles to the +// browser, so it lives on the browser-safe `@sanity/cli/runtime` entry to keep +// `cli-core` out of the frontend bundle. export {type DefineAppInput, unstable_defineApp} from '@sanity/federation' diff --git a/packages/@sanity/cli/src/exports/runtime.ts b/packages/@sanity/cli/src/exports/runtime.ts new file mode 100644 index 000000000..38e001320 --- /dev/null +++ b/packages/@sanity/cli/src/exports/runtime.ts @@ -0,0 +1,11 @@ +// Browser-safe runtime authoring helpers for the workbench extension API. +// +// View/service src files bundle to the browser, so this entry re-exports ONLY +// from `@sanity/federation` (a pure, dependency-light package) — it must never +// import `@sanity/cli-core` or anything Node-only, or it would drag the CLI +// into the frontend bundle. The `sanity` runtime package re-exports from here, +// so the same constraint protects it. +// +// `unstable_defineApp` is config-time (Node) and stays on the main `@sanity/cli` +// entry; only the runtime helpers live here. +export {unstable_defineView} from '@sanity/federation' diff --git a/packages/@sanity/cli/src/server/devServer.ts b/packages/@sanity/cli/src/server/devServer.ts index 8e112e705..ff7641f68 100644 --- a/packages/@sanity/cli/src/server/devServer.ts +++ b/packages/@sanity/cli/src/server/devServer.ts @@ -1,4 +1,5 @@ import {CliConfig, getCliTelemetry, type UserViteConfig} from '@sanity/cli-core' +import {type ViewArtifact} from '@sanity/federation/vite' import {type PluginOptions as ReactCompilerConfig} from 'babel-plugin-react-compiler' import {type FSWatcher} from 'chokidar' import {createServer, type InlineConfig, type ViteDevServer} from 'vite' @@ -28,6 +29,7 @@ export interface DevServerOptions { projectName?: string schemaExtraction?: CliConfig['schemaExtraction'] typegen?: CliConfig['typegen'] + views?: readonly ViewArtifact[] vite?: UserViteConfig } @@ -52,6 +54,7 @@ export async function startDevServer(options: DevServerOptions): Promise=20.19 <22 || >=22.12'} hasBin: true - '@sanity/federation@0.1.0-alpha.9': - resolution: {integrity: sha512-mSsRVzqSrniPtg50+hhbO2WE8vzX/PGkI123ysS8xrUECV0m/v/9YH3ha159dcuUxG0JzgjAtj1+gVy+TmqANQ==} + '@sanity/federation@https://pkg.pr.new/sanity-io/workbench/@sanity/federation@1a9ececc8238fb63e5a8f7bd21b091c2067c2091': + resolution: {integrity: sha512-GS7bCx0GBSSSw6g/qlVHN/uP0sQQHVioC880ljcVHWG0547AF3+Dxet7spl9+ZJVy/PH7VDm24CP/3+YjTOBbA==, tarball: https://pkg.pr.new/sanity-io/workbench/@sanity/federation@1a9ececc8238fb63e5a8f7bd21b091c2067c2091} + version: 0.1.0-alpha.9 engines: {node: '>=20.19.1 <22 || >=22.12'} peerDependencies: vite: ^7.0.0 || ^8.0.0 @@ -14496,7 +14498,7 @@ snapshots: - react-native-b4a - supports-color - '@sanity/federation@0.1.0-alpha.9(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))': + '@sanity/federation@https://pkg.pr.new/sanity-io/workbench/@sanity/federation@1a9ececc8238fb63e5a8f7bd21b091c2067c2091(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@module-federation/runtime': 2.5.0 '@module-federation/vite': 1.16.0(typescript@5.9.3)(vite@7.3.2(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) @@ -14509,7 +14511,7 @@ snapshots: - utf-8-validate - vue-tsc - '@sanity/federation@0.1.0-alpha.9(typescript@5.9.3)(vite@7.3.3(@types/node@25.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))': + '@sanity/federation@https://pkg.pr.new/sanity-io/workbench/@sanity/federation@1a9ececc8238fb63e5a8f7bd21b091c2067c2091(typescript@5.9.3)(vite@7.3.3(@types/node@25.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@module-federation/runtime': 2.5.0 '@module-federation/vite': 1.16.0(typescript@5.9.3)(vite@7.3.3(@types/node@25.0.10)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))