Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/pr-1149.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!-- auto-generated -->
---
'@sanity/cli': minor
---

feat(workbench): build app view artifacts and register them on deploy
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
4 changes: 4 additions & 0 deletions packages/@sanity/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
16 changes: 12 additions & 4 deletions packages/@sanity/cli/src/actions/build/buildApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -45,6 +45,7 @@ interface InternalBuildOptions {
sourceMap: boolean
stats: boolean
unattendedMode: boolean
views: DefineAppInput['views']
vite: UserViteConfig | undefined
workDir: string
}
Expand All @@ -57,14 +58,19 @@ interface InternalBuildOptions {
export async function buildApp(options: BuildOptions): Promise<void> {
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,
Expand All @@ -73,6 +79,7 @@ export async function buildApp(options: BuildOptions): Promise<void> {
sourceMap: Boolean(flags['source-maps']),
stats: flags.stats,
unattendedMode: flags.yes,
views: workbenchApp?.views,
vite: cliConfig.vite,
workDir,
})
Expand Down Expand Up @@ -233,6 +240,7 @@ async function internalBuildApp(options: InternalBuildOptions): Promise<void> {
reactCompiler: options.reactCompiler,
schemaExtraction: options.schemaExtraction,
sourceMap: options.sourceMap,
views: options.views,
vite: options.vite,
})

Expand Down
4 changes: 4 additions & 0 deletions packages/@sanity/cli/src/actions/build/buildStaticFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -36,6 +37,7 @@ interface StaticBuildOptions {
reactCompiler?: ReactCompilerConfig
schemaExtraction?: CliConfig['schemaExtraction']
sourceMap?: boolean
views?: readonly ViewArtifact[]
vite?: UserViteConfig
}

Expand All @@ -61,6 +63,7 @@ export async function buildStaticFiles(
reactCompiler,
schemaExtraction,
sourceMap = false,
views,
vite: extendViteConfig,
} = options

Expand All @@ -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`
Expand Down
13 changes: 11 additions & 2 deletions packages/@sanity/cli/src/actions/build/buildStudio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -50,6 +50,7 @@ interface InternalBuildOptions {
stats: boolean
unattendedMode: boolean
upgradePackages(options: {packages: [name: string, version: string][]}): Promise<void>
views: DefineAppInput['views']
vite: UserViteConfig | undefined
workDir: string
}
Expand All @@ -62,6 +63,11 @@ interface InternalBuildOptions {
export async function buildStudio(options: BuildOptions): Promise<void> {
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<void> => {
Expand All @@ -80,7 +86,7 @@ export async function buildStudio(options: BuildOptions): Promise<void> {
calledFromDeploy,
determineBasePath: () => determineBasePath(cliConfig, 'studio', output),
isApp: determineIsApp(cliConfig),
isWorkbench: isWorkbenchApp(cliConfig?.app),
isWorkbench: Boolean(workbenchApp),
minify: Boolean(flags.minify),
outDir,
output,
Expand All @@ -91,6 +97,7 @@ export async function buildStudio(options: BuildOptions): Promise<void> {
stats: flags.stats,
unattendedMode: Boolean(flags.yes),
upgradePackages: upgradePkgs,
views: workbenchApp?.views,
vite: cliConfig.vite,
workDir,
})
Expand Down Expand Up @@ -118,6 +125,7 @@ async function internalBuildStudio(options: InternalBuildOptions): Promise<void>
stats,
unattendedMode,
upgradePackages,
views,
vite,
workDir,
} = options
Expand Down Expand Up @@ -300,6 +308,7 @@ async function internalBuildStudio(options: InternalBuildOptions): Promise<void>
reactCompiler,
schemaExtraction,
sourceMap,
views,
vite,
})

Expand Down
10 changes: 9 additions & 1 deletion packages/@sanity/cli/src/actions/build/getViteConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -95,6 +95,12 @@ interface ViteOptions extends Pick<CliConfig, 'schemaExtraction' | 'typegen'> {
* 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/<name>`.
*/
views?: readonly ViewArtifact[]
}

/**
Expand All @@ -121,6 +127,7 @@ export async function getViteConfig(options: ViteOptions): Promise<InlineConfig>
// default to `true` when `mode=development`
sourceMap = options.mode === 'development',
typegen,
views,
} = options

const basePath = normalizeBasePath(rawBasePath)
Expand Down Expand Up @@ -218,6 +225,7 @@ export async function getViteConfig(options: ViteOptions): Promise<InlineConfig>
studioConfigPath: entries.relativeConfigLocation!,
}),
pkgJson: await readPackageJson(path.join(cwd, 'package.json')),
views,
workDir: cwd,
}),
]
Expand Down
26 changes: 26 additions & 0 deletions packages/@sanity/cli/src/actions/deploy/deployApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions packages/@sanity/cli/src/actions/deploy/viewDeployment.ts
Original file line number Diff line number Diff line change
@@ -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<typeof viewDeploymentPayloadSchema>

/**
* 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<Record<string, unknown>>
}): ViewDeploymentPayload {
return viewDeploymentPayloadSchema.parse({
applicationId: input.applicationId,
views: input.views ?? [],
})
}
10 changes: 10 additions & 0 deletions packages/@sanity/cli/src/actions/dev/devServerRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])),
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
}
}
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading