diff --git a/apps/host/README.md b/apps/host/README.md index ea3f690..e8b46bb 100644 --- a/apps/host/README.md +++ b/apps/host/README.md @@ -47,6 +47,10 @@ The page consumes it like any other Nuxt component: ``` +### Bridge application remote + +`/bridge/*` mounts `remote/bridge/export-app` via `@module-federation/bridge-vue3` (`createRemoteAppComponent`) as a client-only island. Component federation on `/` is unchanged. The host registers a Vue Router catch-all (`/bridge/:pathMatch(.*)*`) so basename auto-detect works with current `bridge-vue3` releases. + ## SSR behavior Nuxt 4.5 runs development with Vite 8 and Rolldown. Remote components render on the server during `pnpm dev`, then hydrate and remain interactive in the browser. diff --git a/apps/host/app/app.vue b/apps/host/app/app.vue index b457ed5..c55bf94 100644 --- a/apps/host/app/app.vue +++ b/apps/host/app/app.vue @@ -1,29 +1,15 @@ - - diff --git a/apps/host/app/components/BridgeRemotePage.vue b/apps/host/app/components/BridgeRemotePage.vue new file mode 100644 index 0000000..27d0c04 --- /dev/null +++ b/apps/host/app/components/BridgeRemotePage.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/apps/host/app/pages/index.vue b/apps/host/app/pages/index.vue new file mode 100644 index 0000000..5c6a2b3 --- /dev/null +++ b/apps/host/app/pages/index.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/apps/host/app/types/remote.d.ts b/apps/host/app/types/remote.d.ts index 2f5c114..07cf145 100644 --- a/apps/host/app/types/remote.d.ts +++ b/apps/host/app/types/remote.d.ts @@ -9,3 +9,11 @@ declare module "remote/Counter" { const component: Component; export default component; } + +declare module "remote/bridge/export-app" { + const createProvider: () => { + render: (info: Record) => void | Promise; + destroy: (info: { dom: HTMLElement }) => void; + }; + export default createProvider; +} diff --git a/apps/host/nuxt.config.ts b/apps/host/nuxt.config.ts index 7870de6..f392f80 100644 --- a/apps/host/nuxt.config.ts +++ b/apps/host/nuxt.config.ts @@ -9,6 +9,18 @@ export default defineNuxtConfig({ buildCache: false, }, + hooks: { + // Vue Router catch-all name so bridge-vue3 basename auto-detect works + // without waiting for explicit-basename releases. + "pages:extend"(pages) { + pages.push({ + name: "bridge-remote", + path: "/bridge/:pathMatch(.*)*", + file: "~/components/BridgeRemotePage.vue", + }); + }, + }, + moduleFederation: { remoteComponents: { remote: ["Widget", "Counter"], diff --git a/apps/host/package.json b/apps/host/package.json index 48c2a32..34c858f 100644 --- a/apps/host/package.json +++ b/apps/host/package.json @@ -10,6 +10,7 @@ "typecheck": "nuxt typecheck" }, "dependencies": { + "@module-federation/bridge-vue3": "^2.8.2", "@module-federation/nuxt": "workspace:*", "@pinia/nuxt": "0.11.3", "nuxt": "4.5.1", diff --git a/apps/remote/README.md b/apps/remote/README.md index 0e461b0..e45f5b9 100644 --- a/apps/remote/README.md +++ b/apps/remote/README.md @@ -6,6 +6,7 @@ This Nuxt application provides Vue components to the host example and runs stand - Host consumer: `http://localhost:4173` - Configuration: [`nuxt.config.ts`](nuxt.config.ts) - Exposed components: [`app/components/exposed`](app/components/exposed) +- Bridge app export: [`app/export-app.ts`](app/export-app.ts) → `./bridge/export-app` ## Run @@ -33,6 +34,14 @@ The port is fixed because the host configuration points to `4174`. To add another auto-registered component, create `app/components/exposed/Example.vue`. After restarting the applications, a single-remote host can render it as ``. +### Bridge application export + +In addition to components, this remote exposes an application-level Bridge module: + +- `./bridge/export-app` — `createBridgeComponent` wrapping a small vue-router app under `app/bridge/` + +Hosts load it with `@module-federation/bridge-vue3` `createRemoteAppComponent` (see host `/bridge/*`). The same Bridge export can be consumed from React/Next via `@module-federation/bridge-react`. Use a slashed expose name so host manifest discovery does not treat it as a Vue component. + ## Federation assets Development and production serve the federation contract from the public root: @@ -52,4 +61,4 @@ pnpm build pnpm preview ``` -Verify all three federation URLs above return successfully, then open the host at `http://localhost:4173`. Its initial HTML should already include both remote components, and their counters should become interactive after hydration. +Verify all three federation URLs above return successfully, then open the host at `http://localhost:4173`. Its initial HTML should already include both remote components, and their counters should become interactive after hydration. Open `/bridge` to exercise the Bridge remote (client island). diff --git a/apps/remote/app/bridge/App.vue b/apps/remote/app/bridge/App.vue new file mode 100644 index 0000000..adae53b --- /dev/null +++ b/apps/remote/app/bridge/App.vue @@ -0,0 +1,27 @@ + + + diff --git a/apps/remote/app/bridge/pages/Detail.vue b/apps/remote/app/bridge/pages/Detail.vue new file mode 100644 index 0000000..60e1ed2 --- /dev/null +++ b/apps/remote/app/bridge/pages/Detail.vue @@ -0,0 +1,5 @@ + diff --git a/apps/remote/app/bridge/pages/Home.vue b/apps/remote/app/bridge/pages/Home.vue new file mode 100644 index 0000000..fd441f2 --- /dev/null +++ b/apps/remote/app/bridge/pages/Home.vue @@ -0,0 +1,5 @@ + diff --git a/apps/remote/app/bridge/router.ts b/apps/remote/app/bridge/router.ts new file mode 100644 index 0000000..dc8b7bd --- /dev/null +++ b/apps/remote/app/bridge/router.ts @@ -0,0 +1,17 @@ +import { createRouter, createWebHistory } from "vue-router"; +import Home from "./pages/Home.vue"; +import Detail from "./pages/Detail.vue"; + +/** + * Standalone vue-router for the Bridge export. + * Mounted under the host basename (e.g. /bridge) by createBridgeComponent. + */ +export function createBridgeRouter() { + return createRouter({ + history: createWebHistory(), + routes: [ + { path: "/", name: "bridge-home", component: Home }, + { path: "/detail", name: "bridge-detail", component: Detail }, + ], + }); +} diff --git a/apps/remote/app/export-app.ts b/apps/remote/app/export-app.ts new file mode 100644 index 0000000..6852898 --- /dev/null +++ b/apps/remote/app/export-app.ts @@ -0,0 +1,14 @@ +import { createBridgeComponent } from "@module-federation/bridge-vue3"; +import App from "./bridge/App.vue"; +import { createBridgeRouter } from "./bridge/router"; + +/** + * Application-level Bridge export for hosts (Nuxt, Vue, React, …). + * Component federation (./Widget, ./Counter) is unchanged. + */ +export default createBridgeComponent({ + rootComponent: App, + appOptions: () => ({ + router: createBridgeRouter(), + }), +}); diff --git a/apps/remote/nuxt.config.ts b/apps/remote/nuxt.config.ts index d648457..1587524 100644 --- a/apps/remote/nuxt.config.ts +++ b/apps/remote/nuxt.config.ts @@ -15,6 +15,11 @@ export default defineNuxtConfig({ filename: "remoteEntry.js", remotes: {}, manifest: true, + exposes: { + // Slash in the expose name keeps Bridge exports out of component + // auto-registration (host manifest discovery). + "./bridge/export-app": "./app/export-app.ts", + }, }, }, vite: { diff --git a/apps/remote/package.json b/apps/remote/package.json index 6a9e857..2843c06 100644 --- a/apps/remote/package.json +++ b/apps/remote/package.json @@ -10,6 +10,7 @@ "typecheck": "nuxt typecheck" }, "dependencies": { + "@module-federation/bridge-vue3": "^2.8.2", "@module-federation/nuxt": "workspace:*", "nuxt": "4.5.1", "ufo": "1.6.4", diff --git a/e2e/nuxt.spec.mjs b/e2e/nuxt.spec.mjs index 8e9edd4..460cfc8 100644 --- a/e2e/nuxt.spec.mjs +++ b/e2e/nuxt.spec.mjs @@ -26,3 +26,28 @@ test("host and SSR remote hydrate", async ({ page }) => { page.getByRole("button", { name: /Remote counter: 1/ }), ).toHaveCount(2); }); + +test("Bridge remote app keeps its basename while navigating", async ({ + page, +}) => { + await page.goto("/bridge"); + + await expect( + page.getByRole("heading", { name: "Bridge remote app" }), + ).toBeVisible(); + await expect(page.getByText("Bridge home route.")).toBeVisible(); + + await page.getByRole("link", { name: "Detail", exact: true }).click(); + await expect(page).toHaveURL(/\/bridge\/detail$/); + await expect(page.getByText(/Bridge detail route/)).toBeVisible(); + + await page.goto("/bridge/detail"); + await expect(page.getByText(/Bridge detail route/)).toBeVisible(); + + await page.getByRole("link", { name: "Home", exact: true }).click(); + await expect(page).toHaveURL(/\/bridge\/?$/); + await expect(page.getByText("Bridge home route.")).toBeVisible(); + + await page.getByRole("link", { name: "Components", exact: true }).click(); + await expect(page).toHaveURL(/\/$/); +}); diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index a6f69c3..b496168 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -110,6 +110,41 @@ With multiple configured remotes, the remote name is included to prevent collisi Only expose names beginning with a letter and containing letters, numbers, underscores, or hyphens are registered as Nuxt components. Other MF exposes remain available through normal runtime imports. +### Bridge application export (optional) + +To expose a full routing app (not only components), install the Bridge Vue 3 adapter and its router peer, then add a Bridge entry and list it under `config.exposes`: + +```sh +pnpm add @module-federation/bridge-vue3@2.8.2 vue-router@5.2.0 +``` + +Nuxt `4.5.1` requires `vue-router@^5.2.0`. The latest published `@module-federation/bridge-vue3` is `2.8.2` and declares the older `vue-router@4` peer, so this Nuxt example intentionally keeps Nuxt's required Router 5 rather than silently installing or suppressing a conflicting peer. Bridge 2.8.2 uses the Router APIs shared by these versions; the example's Playwright coverage verifies the `/bridge` basename and child navigation. For a non-Nuxt host, follow the adapter's declared peer contract and use Vue Router 4. + +```ts +// app/export-app.ts +import { createBridgeComponent } from "@module-federation/bridge-vue3"; +import App from "./bridge/App.vue"; +import { createBridgeRouter } from "./bridge/router"; + +export default createBridgeComponent({ + rootComponent: App, + appOptions: () => ({ router: createBridgeRouter() }), +}); +``` + +```ts +moduleFederation: { + config: { + name: "catalog", + exposes: { + "./bridge/export-app": "./app/export-app.ts", + }, + }, +} +``` + +Hosts load it with `createRemoteAppComponent` from `@module-federation/bridge-vue3` (or `@module-federation/bridge-react` for React/Next). Use a host catch-all such as `/catalog/:pathMatch(.*)*` so current `bridge-vue3` basename auto-detect works. An explicit `basename` option is tracked in [module-federation/core#4984](https://github.com/module-federation/core/pull/4984). Use a slashed expose name (e.g. `./bridge/export-app`) so host manifest discovery does not register the Bridge factory as a Nuxt component. See the example apps under `apps/host` and `apps/remote`. + ## Server rendering `ssr` defaults to `true`. When Nuxt SSR is enabled, the module creates client and server federation builds: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4549862..630632f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: apps/host: dependencies: + '@module-federation/bridge-vue3': + specifier: ^2.8.2 + version: 2.8.2(vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(esbuild@0.28.1)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.2.0)(rollup@4.62.3)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3)) '@module-federation/nuxt': specifier: workspace:* version: link:../../packages/nuxt @@ -65,6 +68,9 @@ importers: apps/remote: dependencies: + '@module-federation/bridge-vue3': + specifier: ^2.8.2 + version: 2.8.2(vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(esbuild@0.28.1)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.2.0)(rollup@4.62.3)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3)) '@module-federation/nuxt': specifier: workspace:* version: link:../../packages/nuxt @@ -658,6 +664,15 @@ packages: engines: {node: '>=18'} hasBin: true + '@module-federation/bridge-shared@2.8.2': + resolution: {integrity: sha512-pgK9PxLfK8voZztF3ONDjmUgNdOsDL1JmXJaw9d5I2LcbQQuGWFnk23DE66BFqeBI+x6wls+5Cmm/CiW1I/zhQ==} + + '@module-federation/bridge-vue3@2.8.2': + resolution: {integrity: sha512-kpddUr/dydVkDxAjO/Q1pwAO71hvnXUrBCexJWLKf4SZaYUae9uGShqEqHBhQLjOzJlByGREd0Lvmqfwca9WuA==} + peerDependencies: + vue: '=3' + vue-router: '=4' + '@module-federation/dts-plugin@2.8.0': resolution: {integrity: sha512-defjq4jOWMEfeejezPWLP5sc8kw0O6FqTT7/E5rbZPEVyjB1A0U3ynhW6GDE5/6hk9/TzdbWS+fBNi4MqUOY6Q==} peerDependencies: @@ -673,6 +688,9 @@ packages: '@module-federation/error-codes@2.8.1': resolution: {integrity: sha512-0mQ+bWt1LRCZyURx3g2b8G+aAlvk8iXIgrp3Jit/75blrlVda/eVqnHz1L+YOxwkP3xSrdbUb4423AoWti31ZQ==} + '@module-federation/error-codes@2.8.2': + resolution: {integrity: sha512-8inlDv48QOjA//CLQ3epjoHEiMQGsz1Pmtu2N+s7gQVggn6AYHpjnMe8AsyGxtpaPg3wbX0HmBZtRFggpXUB9A==} + '@module-federation/managers@2.8.0': resolution: {integrity: sha512-SnVBCwmi962WGg6hLFElxZUCnrRJdR6glE2ZKPBY/iK07AHUN2ZxuaBCBsVzyws+xLZGHZxBHmVstijTh8dSUA==} @@ -682,18 +700,27 @@ packages: '@module-federation/runtime-core@2.8.1': resolution: {integrity: sha512-Dif+3u7fvq6qBATFIv5qB7ay6Rgo2HNzhaNOt1yRfpVXjiJqQ3UPnHZ+TLP9znkXiZvg6Bg5W9EV2ZX4nH2S0w==} + '@module-federation/runtime-core@2.8.2': + resolution: {integrity: sha512-PEkkK9MUp+nUCeQMS4ox3QGZfwwxgfjGA7P4umEnr5c3y8DLNDR+26tyHf/Gkjen2VsjlcyL+mEAQo1Zi4IE3g==} + '@module-federation/runtime@2.8.0': resolution: {integrity: sha512-cGtUBQ1/TVy7KrXy6xPgy3FEmOGyIYkBA2T4iGH3ZH5PNPPTmqN9jF2AfneTSOj0RtBr7Pxq3CUt81E/UCvK1A==} '@module-federation/runtime@2.8.1': resolution: {integrity: sha512-+xpq/r6Om4plbGJisZp6/rl7usEUlQszz34E+JUKgl9uW3QIRzVgxwOAzGiF7U+dqOKxMqmiq+nTJwK2wAwteA==} + '@module-federation/runtime@2.8.2': + resolution: {integrity: sha512-SUoP+PD5EjSPSi6FxEPGIZoRkFifxdeYcVQbJE9mO0VEjF51gAk3/TgX8k0vzUryOBPmXekLr9SfQXU6DqUtvA==} + '@module-federation/sdk@2.8.0': resolution: {integrity: sha512-yBP+9+0Z8nlvKEXAZS3AsQVy7bFbZf8eMivGk4q4ZdwG3TsLMlsPjb1dQb2i7gcAG6ux9y2LWLkj/0LVk74cnQ==} '@module-federation/sdk@2.8.1': resolution: {integrity: sha512-3EVljiNilY2pFIG2RO4KNCC6gIPnYc9J+p5U6Nn8D5X3PtJeEcPyBvKGttxZnSLlXCi+YXQfgexBOfnkvEuzuQ==} + '@module-federation/sdk@2.8.2': + resolution: {integrity: sha512-OPS/lbQjraLXoWniQpCwQ/vqgURHTrhsackSNcOPmcJHM3LyR+DabxUc0pl8jAqExsW2l+uepQq7+/Gkei871w==} + '@module-federation/third-party-dts-extractor@2.8.0': resolution: {integrity: sha512-nAMlr74OKIylkfRwlunOhytQbmsgb3gCqdXWnPQhG+ZtqWXGELLfMT4a1Q1ht3cS+sRpWj2SZRqK2M7GadI6tA==} @@ -5288,6 +5315,16 @@ snapshots: - encoding - supports-color + '@module-federation/bridge-shared@2.8.2': {} + + '@module-federation/bridge-vue3@2.8.2(vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(esbuild@0.28.1)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.2.0)(rollup@4.62.3)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3))': + dependencies: + '@module-federation/bridge-shared': 2.8.2 + '@module-federation/runtime': 2.8.2 + '@module-federation/sdk': 2.8.2 + vue: 3.5.40(typescript@6.0.3) + vue-router: 5.2.0(@vue/compiler-sfc@3.5.40)(esbuild@0.28.1)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.2.0)(rollup@4.62.3)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) + '@module-federation/dts-plugin@2.8.0(typescript@7.0.2)(vue-tsc@3.3.8(typescript@7.0.2))': dependencies: '@module-federation/error-codes': 2.8.0 @@ -5309,6 +5346,8 @@ snapshots: '@module-federation/error-codes@2.8.1': {} + '@module-federation/error-codes@2.8.2': {} + '@module-federation/managers@2.8.0': dependencies: '@module-federation/sdk': 2.8.0 @@ -5323,6 +5362,11 @@ snapshots: '@module-federation/error-codes': 2.8.1 '@module-federation/sdk': 2.8.1 + '@module-federation/runtime-core@2.8.2': + dependencies: + '@module-federation/error-codes': 2.8.2 + '@module-federation/sdk': 2.8.2 + '@module-federation/runtime@2.8.0': dependencies: '@module-federation/error-codes': 2.8.0 @@ -5335,10 +5379,18 @@ snapshots: '@module-federation/runtime-core': 2.8.1 '@module-federation/sdk': 2.8.1 + '@module-federation/runtime@2.8.2': + dependencies: + '@module-federation/error-codes': 2.8.2 + '@module-federation/runtime-core': 2.8.2 + '@module-federation/sdk': 2.8.2 + '@module-federation/sdk@2.8.0': {} '@module-federation/sdk@2.8.1': {} + '@module-federation/sdk@2.8.2': {} + '@module-federation/third-party-dts-extractor@2.8.0': {} '@module-federation/vite@1.20.0(typescript@7.0.2)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(vue-tsc@3.3.8(typescript@7.0.2))': diff --git a/test/helpers/release.mjs b/test/helpers/release.mjs index 4555a8f..ffa09ac 100644 --- a/test/helpers/release.mjs +++ b/test/helpers/release.mjs @@ -3,6 +3,7 @@ import { spawn } from "node:child_process"; import { once } from "node:events"; import { existsSync } from "node:fs"; import { + cp, mkdtemp, readdir, readFile, @@ -21,8 +22,9 @@ export const repoRoot = resolve( export async function createNuxtFixture(app, config = {}) { const layer = resolve(repoRoot, "apps", app); const root = await mkdtemp(join(layer, ".nuxt-mf-test-")); + await cp(resolve(layer, "app"), resolve(root, "app"), { recursive: true }); const source = `export default ${JSON.stringify( - { extends: [layer], srcDir: resolve(layer, "app"), ...config }, + { extends: [layer], srcDir: resolve(root, "app"), ...config }, null, 2, )};\n`; diff --git a/test/release-z-build-variants.test.mjs b/test/release-z-build-variants.test.mjs index 1ed600f..d5360d2 100644 --- a/test/release-z-build-variants.test.mjs +++ b/test/release-z-build-variants.test.mjs @@ -33,6 +33,40 @@ test( }, ); +test( + "isolated Nuxt fixtures resolve root-relative configured exposes", + { timeout: 90_000 }, + async (context) => { + const fixtureRoot = await createNuxtFixture("remote"); + context.after(() => rm(fixtureRoot, { force: true, recursive: true })); + + assert.ok( + existsSync(resolve(fixtureRoot, "app/export-app.ts")), + "fixture does not contain the app source used by config.exposes", + ); + await runCommand(process.execPath, [ + nuxtCliPath("remote"), + "build", + fixtureRoot, + ]); + + assert.ok( + existsSync(resolve(fixtureRoot, ".output/public/remoteEntry.ssr.js")), + "fixture build did not publish the configured Bridge expose", + ); + const manifest = JSON.parse( + await readFile( + resolve(fixtureRoot, ".output/public/mf-manifest.json"), + "utf8", + ), + ); + assert.ok( + manifest.exposes?.some(({ path }) => path === "./bridge/export-app"), + "fixture manifest does not contain the configured Bridge expose", + ); + }, +); + test( "disabled remote SSR does not bundle the writable cache loader", { timeout: 45_000 },