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/workbench-federated-remote-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sanity/workbench-cli': minor
'@sanity/cli-build': patch
---

feat(workbench-cli): generate `@mf-types` for federated remotes
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,14 @@ export async function getViteConfig(options: ViteOptions): Promise<InlineConfig>
...(isWorkbenchApp
? [
...sharedPlugins,
await workbenchVitePlugins({appId: workbenchAppId, cwd, entries, exposes, isApp}),
await workbenchVitePlugins({
appId: workbenchAppId,
cwd,
dev: mode === 'development',
entries,
exposes,
isApp,
}),
]
: [
...sharedPlugins,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export const FEDERATION_FILE_NAME = 'remote-entry'
export const FEDERATION_DIR_NAME = 'federation'
export const RUNTIME_DIR = `.sanity/${FEDERATION_DIR_NAME}`

// Project-root-relative path of the dts tsconfig, shared so `sanityFederationTypes`
// (writes it) and `sanityModuleFederation` (points type generation at it) can't drift.
export const DTS_TSCONFIG_PATH = `${RUNTIME_DIR}/tsconfig.json`
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type FederationRuntimeOptions,
sanityFederationRuntime,
} from './plugins/plugin-sanity-federation-runtime.js'
import {sanityFederationTypes} from './plugins/plugin-sanity-federation-types.js'

interface FederationPluginOptionsBase extends Omit<Partial<FederationOptions>, 'exposes'> {
exposes?: WorkbenchExposes
Expand Down Expand Up @@ -60,7 +61,7 @@ type FederationPluginOptions = AppFederationPluginOptions | StudioFederationPlug
* @internal
*/
export const federation = (options: FederationPluginOptions): PluginOption => {
const {exposes, name: defaultName, pkgJson, workDir = process.cwd()} = options
const {dev, exposes, name: defaultName, pkgJson, workDir = process.cwd()} = options

let name = defaultName

Expand Down Expand Up @@ -115,6 +116,7 @@ export const federation = (options: FederationPluginOptions): PluginOption => {
sanityEnvironmentPlugin({input: entryPath}),
sanityFederationRuntime(runtimeOptions),
sanityExtensionArtifacts({artifacts}),
sanityModuleFederation({exposes: federationExposes, name}),
sanityFederationTypes(),
sanityModuleFederation({dev, exposes: federationExposes, name}),
]
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {type Environment, type Plugin} from 'vite'
import {afterEach, describe, expect, it, vi} from 'vitest'

import {FEDERATION_DIR_NAME} from '../constants.js'
import {DTS_TSCONFIG_PATH, FEDERATION_DIR_NAME} from '../constants.js'
import {sanityModuleFederation} from './plugin-module-federation.js'

const mockFederation = vi.hoisted(() => vi.fn())
Expand All @@ -27,18 +27,28 @@ function appliesTo(plugin: Plugin, name: string, command: 'build' | 'serve'): bo
}

describe('sanityModuleFederation', () => {
// Regression test for TYPE-001: upstream defaults dts generation on for any
// project with a tsconfig.json, but it compiles the generated .js/.jsx
// expose shims with the user's compiler options — tsc rejects them without
// allowJs (TS6504), and declaration emit of the user's noEmit app code fails
// on its own (TS2742/TS4082). Type generation must stay explicitly off.
it('disables dts type generation so the user tsconfig never compiles the generated exposes', () => {
// Type generation compiles the exposes with a tsconfig the build owns, not the
// app's — so real projects generate types without their own compiler options
// breaking the compile — and stays best-effort so a failure never fails the build.
it('generates dts from the build-owned tsconfig, best-effort', () => {
mockFederation.mockReturnValue([])

runPlugin()

expect(mockFederation).toHaveBeenCalledTimes(1)
expect(mockFederation.mock.calls[0][0].dts).toEqual({generateTypes: false})
expect(mockFederation.mock.calls[0][0].dts).toEqual({
consumeTypes: true,
displayErrorInTerminal: false,
generateTypes: {abortOnError: false, tsConfigPath: DTS_TSCONFIG_PATH},
})
})

it('surfaces type-generation errors in the terminal only for the dev server', () => {
mockFederation.mockReturnValue([])

sanityModuleFederation({dev: true, exposes: {}, name: 'test-app'})

expect(mockFederation.mock.calls[0][0].dts.displayErrorInTerminal).toBe(true)
})

it('scopes plugins to the dev server and the federation build environment', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {federation as moduleFederation, type ModuleFederationOptions} from '@module-federation/vite'
import {type Plugin, type PluginOption} from 'vite'

import {FEDERATION_DIR_NAME, FEDERATION_FILE_NAME} from '../constants.js'
import {DTS_TSCONFIG_PATH, FEDERATION_DIR_NAME, FEDERATION_FILE_NAME} from '../constants.js'

/**
* @internal
Expand All @@ -13,23 +13,30 @@ export interface FederationOptions extends Pick<ModuleFederationOptions, 'expose
* defaults to your package.json name if not provided.
*/
name: string

/**
* Dev server build. Type generation is best-effort and never fails the build;
* in dev we surface its errors in the terminal, in production we keep them silent.
*/
dev?: boolean
}

export function sanityModuleFederation({exposes, name}: FederationOptions): PluginOption {
export function sanityModuleFederation({dev, exposes, name}: FederationOptions): PluginOption {
const mfPlugins = moduleFederation({
dev: {
disableDynamicRemoteTypeHints: true,
remoteHmr: true,
},
// Remote type generation stays off: it compiles the exposes with the
// project's tsconfig, and that breaks twice over on real projects.
// The exposes are generated .js/.jsx shims, which tsc refuses without
// allowJs (TYPE-001/TS6504) — no app template sets it. And with allowJs
// worked around, declaration emit pulls in the user's own modules, which
// are noEmit projects never written to be declaration-emittable: TS2742
// (non-portable inferred types, endemic under pnpm) and TS4082 (private
// names in default exports) then fail the compile just the same.
dts: {generateTypes: false},
dts: {
// On so a remote picks up another remote's published types once workbench
// apps compose each other's exposes. Inert until then — nothing configures
// `remotes` to consume, and the build only consumes when `typesOnBuild` is set.
consumeTypes: true,
displayErrorInTerminal: Boolean(dev),
// Compile from the build-owned tsconfig (see `sanityFederationTypes`), not
// the app's. `abortOnError: false` keeps generation from failing the build.
generateTypes: {abortOnError: false, tsConfigPath: DTS_TSCONFIG_PATH},
},
exposes,
filename: `${FEDERATION_FILE_NAME}.js`,
manifest: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import fs from 'node:fs'
import path from 'node:path'

import {type Plugin} from 'vite'

import {DTS_TSCONFIG_PATH} from '../constants.js'

// The tsconfig `@module-federation/vite` compiles the exposes with — the build
// owns it rather than reusing the app's, whose options break the compile.
// - `allowJs`: the exposes are generated `.js`/`.jsx` shims; no app template sets it.
// - `rootDir: '.'` + `noResolve`: keep the program to the shims alone. They import
// the app's modules to render them; resolving those drags noEmit app code into
// declaration emit, which fails on real projects (TS6059 outside rootDir,
// TS2742/TS4082 on types never meant to emit). Opaque imports can't break it.
// - `skipLibCheck` + `types: []`: with imports opaque the shims need no `@types`;
// loading them (e.g. `@types/react`) only adds lib-check errors.
const DTS_TSCONFIG = {
compilerOptions: {allowJs: true, noResolve: true, rootDir: '.', skipLibCheck: true, types: []},
}

/**
* Writes {@link DTS_TSCONFIG} into the runtime dir, next to the exposes
* `@module-federation/vite` points its type generation at.
*/
export function sanityFederationTypes(): Plugin {
return {
configResolved(config) {
const tsconfigPath = path.resolve(config.root, DTS_TSCONFIG_PATH)
fs.mkdirSync(path.dirname(tsconfigPath), {recursive: true})
fs.writeFileSync(tsconfigPath, JSON.stringify(DTS_TSCONFIG, null, 2))
},
name: 'sanity/federation-types',
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ interface WorkbenchViteOptions {
/** The app's bus identity, stamped into its modules for `@sanity/runtime`. */
appId?: string

/** Dev server build — surfaces type-generation errors only in dev, not production. */
dev?: boolean
exposes?: WorkbenchExposes
/** App (vs studio) build — selects the discriminated federation option shape. */
isApp?: boolean
Expand All @@ -51,10 +53,11 @@ function requireStudioConfigPath(relativeConfigLocation: string | null): string

/** Build the Vite plugins for a workbench app's module-federation remote. */
export async function workbenchVitePlugins(options: WorkbenchViteOptions): Promise<PluginOption> {
const {appId, cwd, entries, exposes, isApp} = options
const {appId, cwd, dev, entries, exposes, isApp} = options
const pkgJson = await readPackageJson(path.join(cwd, 'package.json'))

const federationPlugin = federation({
dev,
...(isApp
? {
// `null` relativeEntry (a branded app with no `entry`) → omit `appEntry`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'

import viteReact from '@vitejs/plugin-react'
import {createBuilder, type PluginOption} from 'vite'
import {afterAll, beforeAll, describe, expect, it} from 'vitest'

import {federation} from '../../../../../../src/actions/build/vite/plugin.js'

// A workbench remote exposes generated render-contract shims (`.js`/`.jsx`), not
// the app's own source. This proves `@module-federation/vite` generates their
// `@mf-types` from that shim shape — the compile succeeds (an `@mf-types.zip`
// is only written when tsc exits clean) and the emitted declarations carry the
// render contract. It fails against a build that leaves type generation off.

// `@module-federation/vite` returns an empty plugin set when it detects vitest in
// the env; opt out so the real dts plugins run.
process.env.MFE_VITE_NO_TEST_ENV_CHECK = 'true'

// federated-studio is a workspace fixture with react installed; borrow its
// node_modules so the temp remote's shims (react, react-dom) resolve when bundled.
const FIXTURE_NODE_MODULES = path.resolve(
__dirname,
'../../../../../../../../../fixtures/federated-studio/node_modules',
)

let cwd: string
let dist: string

beforeAll(async () => {
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'wb-generate-types-'))
dist = path.join(cwd, 'dist')
fs.symlinkSync(FIXTURE_NODE_MODULES, path.join(cwd, 'node_modules'), 'dir')

fs.mkdirSync(path.join(cwd, 'src', 'views'), {recursive: true})
fs.writeFileSync(
path.join(cwd, 'package.json'),
JSON.stringify({name: 'type-gen-remote', private: true, type: 'module', version: '0.0.0'}),
)
fs.writeFileSync(
path.join(cwd, 'src', 'App.tsx'),
'export default function App({title}: {title: string}) {\n return <div>{title}</div>\n}\n',
)
fs.writeFileSync(
path.join(cwd, 'src', 'views', 'panel.tsx'),
'const view = {version: 1, components: {title: () => null, panel: () => null}}\nexport default view\n',
)

const plugins = federation({
appEntry: '../../src/App.tsx',
exposes: {views: [{name: 'feed', src: 'src/views/panel.tsx', type: 'panel'}]},
isApp: true,
pkgJson: {name: 'type-gen-remote', version: '0.0.0'},
workDir: cwd,
})

const builder = await createBuilder({
build: {outDir: dist},
configFile: false,
logLevel: 'silent',
plugins: [viteReact(), plugins as PluginOption],
root: cwd,
})
await builder.buildApp()
}, 120_000)

afterAll(() => {
fs.rmSync(cwd, {force: true, recursive: true})
})

const compiledType = (relativeExpose: string) =>
fs.readFileSync(path.join(dist, '@mf-types', 'compiled-types', relativeExpose), 'utf8')

describe('workbench remote type generation', () => {
it('packages the exposes into a consumable @mf-types bundle', () => {
// The zip and the loadRemote API declaration only land when tsc exits clean.
expect(fs.existsSync(path.join(dist, '@mf-types.zip'))).toBe(true)
expect(fs.existsSync(path.join(dist, '@mf-types.d.ts'))).toBe(true)
expect(fs.existsSync(path.join(dist, '@mf-types', 'App.d.ts'))).toBe(true)
})

it('emits the render contract for the app entry', () => {
expect(compiledType('remote-entry.d.ts')).toContain('export function render(')
})

it('emits the render contract and version for each view component', () => {
for (const component of ['title', 'panel']) {
const declaration = compiledType(`views/feed/${component}.d.ts`)
expect(declaration).toContain('export function render(')
expect(declaration).toContain('export const version')
}
})

it('never compiles the app source into declarations', () => {
// The shims import the app's modules only to render them; declaration emit
// stays scoped to the shims, so the app's own source is never type-emitted.
expect(fs.existsSync(path.join(dist, '@mf-types', 'compiled-types', 'src'))).toBe(false)
})
})
Loading