From 9b4ed675d0b4787fcdd2b8da1d46095aa5bddea2 Mon Sep 17 00:00:00 2001 From: Gustav Hansen Date: Tue, 7 Jul 2026 11:05:43 +0200 Subject: [PATCH] feat(workbench-cli): generate @mf-types for federated remotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type generation was off because @module-federation/vite compiled the exposes with the app's own tsconfig, which broke on real projects: the exposes are generated .js/.jsx render-contract shims the compiler rejects without allowJs, and declaration emit then dragged in the app's noEmit source (TS2742/TS4082). Compile from a tsconfig the build owns instead, scoped with noResolve so only the shims are emitted — the app's own types can never break it. Best-effort: generation never fails the build, and errors surface in dev but stay silent in production. --- .../workbench-federated-remote-types.md | 6 ++ .../src/actions/build/getViteConfig.ts | 9 +- .../src/actions/build/vite/constants.ts | 4 + .../src/actions/build/vite/plugin.ts | 6 +- .../plugin-module-federation.node.test.ts | 26 +++-- .../vite/plugins/plugin-module-federation.ts | 29 +++-- .../plugins/plugin-sanity-federation-types.ts | 34 ++++++ .../build/vite/workbench-vite-plugins.ts | 5 +- .../plugins/generate-types.real.node.test.ts | 100 ++++++++++++++++++ 9 files changed, 196 insertions(+), 23 deletions(-) create mode 100644 .changeset/workbench-federated-remote-types.md create mode 100644 packages/@sanity/workbench-cli/src/actions/build/vite/plugins/plugin-sanity-federation-types.ts create mode 100644 packages/@sanity/workbench-cli/test/integration/actions/build/vite/plugins/generate-types.real.node.test.ts diff --git a/.changeset/workbench-federated-remote-types.md b/.changeset/workbench-federated-remote-types.md new file mode 100644 index 0000000000..a7e035c930 --- /dev/null +++ b/.changeset/workbench-federated-remote-types.md @@ -0,0 +1,6 @@ +--- +'@sanity/workbench-cli': minor +'@sanity/cli-build': patch +--- + +feat(workbench-cli): generate `@mf-types` for federated remotes diff --git a/packages/@sanity/cli-build/src/actions/build/getViteConfig.ts b/packages/@sanity/cli-build/src/actions/build/getViteConfig.ts index 9cb8e97f32..430d922cae 100644 --- a/packages/@sanity/cli-build/src/actions/build/getViteConfig.ts +++ b/packages/@sanity/cli-build/src/actions/build/getViteConfig.ts @@ -207,7 +207,14 @@ export async function getViteConfig(options: ViteOptions): Promise ...(isWorkbenchApp ? [ ...sharedPlugins, - await workbenchVitePlugins({appId: workbenchAppId, cwd, entries, exposes, isApp}), + await workbenchVitePlugins({ + appId: workbenchAppId, + cwd, + dev: mode === 'development', + entries, + exposes, + isApp, + }), ] : [ ...sharedPlugins, diff --git a/packages/@sanity/workbench-cli/src/actions/build/vite/constants.ts b/packages/@sanity/workbench-cli/src/actions/build/vite/constants.ts index 7725f1205d..c59f3889cf 100644 --- a/packages/@sanity/workbench-cli/src/actions/build/vite/constants.ts +++ b/packages/@sanity/workbench-cli/src/actions/build/vite/constants.ts @@ -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` diff --git a/packages/@sanity/workbench-cli/src/actions/build/vite/plugin.ts b/packages/@sanity/workbench-cli/src/actions/build/vite/plugin.ts index 068b158cd2..d1743370d1 100644 --- a/packages/@sanity/workbench-cli/src/actions/build/vite/plugin.ts +++ b/packages/@sanity/workbench-cli/src/actions/build/vite/plugin.ts @@ -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, 'exposes'> { exposes?: WorkbenchExposes @@ -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 @@ -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}), ] } diff --git a/packages/@sanity/workbench-cli/src/actions/build/vite/plugins/plugin-module-federation.node.test.ts b/packages/@sanity/workbench-cli/src/actions/build/vite/plugins/plugin-module-federation.node.test.ts index e8881418c0..36e23279b6 100644 --- a/packages/@sanity/workbench-cli/src/actions/build/vite/plugins/plugin-module-federation.node.test.ts +++ b/packages/@sanity/workbench-cli/src/actions/build/vite/plugins/plugin-module-federation.node.test.ts @@ -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()) @@ -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', () => { diff --git a/packages/@sanity/workbench-cli/src/actions/build/vite/plugins/plugin-module-federation.ts b/packages/@sanity/workbench-cli/src/actions/build/vite/plugins/plugin-module-federation.ts index d1ad03c034..4c35cd13f1 100644 --- a/packages/@sanity/workbench-cli/src/actions/build/vite/plugins/plugin-module-federation.ts +++ b/packages/@sanity/workbench-cli/src/actions/build/vite/plugins/plugin-module-federation.ts @@ -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 @@ -13,23 +13,30 @@ export interface FederationOptions extends Pick { - 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`, diff --git a/packages/@sanity/workbench-cli/test/integration/actions/build/vite/plugins/generate-types.real.node.test.ts b/packages/@sanity/workbench-cli/test/integration/actions/build/vite/plugins/generate-types.real.node.test.ts new file mode 100644 index 0000000000..14189d9302 --- /dev/null +++ b/packages/@sanity/workbench-cli/test/integration/actions/build/vite/plugins/generate-types.real.node.test.ts @@ -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
{title}
\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) + }) +})