Federated Modules with Vite and vite-plugin-federation

Photo by Kaleidico on Unsplash

Federated Modules with Vite and vite-plugin-federation

Introduction

I work on an application at work that has an interesting (and maybe overcomplicated) setup. It's a "web portal" deal which handles authentication of users and gives permissions to a set of "modules". The web portal and each of these modules are all separate Laravel applications. The web portal has some InertiaJS/Vue CRUD interfaces for managing user, permissions, roles, and modules. The modules all have Laravel backends and one or two frontends, at least one of which is a "admin panel" Vue SPA using Vue Router to handle a handful of client-side routes. The web portal application keeps information related to the modules. This includes the links to the build assets for the admin panel Vue SPA. Then at a special route, the module admin panel Vue SPA is mounted on the page. The web portal navigation is put in Vue islands (top nav, side nav, etc) and coexist with the admin panel SPA.

The Problem

Now you understand the kind of odd setup I have, I wanted to be able to share Vue components between the web portal and the separate modules. The modules often have very similar interfaces or components and it would be great to get consistency and code reusability between them. Particularly for some form structures/elements. Any updates could be done in one place and instantly propagated everywhere. But besides trying to publish the components to npm (don't even want to think about that ugh shudder)... how can I accomplish this?

The Solution

Enter federated modules. Federated modules allow components to be loaded at runtime from an external URL. For this to work, you need a remote application to serve components and a (at least one) host application to consume them. For this I used vite-plugin-federation. This assumes both your remote and host applications use Vite or Rollup to bundle assets. Or even Webpack (I haven't tried this myself). My project is sharing Vue components, so additionally, we must tell the plugin that the Vue dependency should be "shared". The setup is pretty straightforward, but there are some gotchas I ran into that I'll share.

A First Naive Approach

Since my setup exists in the context of a Laravel/Inerita/Vue application for the bulk of the application and one specific route handles the special case where we need to load a module SPA, I created separate Vite configs for the Inertia app and the external module route. This was primarily because Vite only outputs one build by default and we have two separate Laravel routes/Blade files which need to load different bundles. The external module Vite config I tried first for the remote app for sharing some form structure components:

// vite-external.config.js (remote)
import { defineConfig } from 'vite';
import federation from '@originjs/vite-plugin-federation';
import topLevelAwait from 'vite-plugin-top-level-await';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
    plugins: [
        laravel({
            input: 'resources/js/external-module.js',
            buildDirectory: 'build-external',
            refresh: true,
        }),
        vue({
            template: {
                transformAssetUrls: {
                    base: null,
                    includeAbsolute: false,
                },
            },
        }),
        topLevelAwait({
            // The export name of top-level await promise for each chunk module
            promiseExportName: '__tla',
            // The function to generate import names of top-level await promise in each chunk module
            promiseImportName: (i) => `__tla_${i}`,
        }),
        federation({
            name: 'shared-components',
            filename: 'remoteEntry.js',
            exposes: {
                './FormSection': './resources/js/Components/Form/FormSection.vue',
                './Left': './resources/js/Components/Form/Left.vue',
                './Right': './resources/js/Components/Form/Right.vue',
            },
            shared: ['vue'],
        }),
    ],
});

Paired with the config for the host app:

// vite-admin.config.js (host)
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
import federation from '@originjs/vite-plugin-federation';
import topLevelAwait from 'vite-plugin-top-level-await';

export default defineConfig({
  plugins: [
    laravel({
      input: 'resources/js/admin/index.js',
      buildDirectory: 'build-admin',
    }),
    vue({
      template: {
        transformAssetUrls: {
          base: null,
          includeAbsolute: false,
        },
      },
    }),
    topLevelAwait({
      // The export name of top-level await promise for each chunk module
      promiseExportName: "__tla",
      // The function to generate import names of top-level await promise in each chunk module
      promiseImportName: i => `__tla_${i}`
    }),
    federation({
      name: 'host-app',
      remotes: {
        'shared-components': 'https://remote-app.com/build-external/assets/remoteEntry.js',
      },
      shared: ['vue'],
    }),
  ],
});

And every thing seemed to be working... until it wasn't. The Vue components I was sharing were mostly a thin wrapper around some styling. But in one of them I wasn't to do something fancy with the slots and I imported the useSlots function from vue to help with this.

// resources/js/Components/Form/FormSection.vue (remote)
import { useSlots } from 'vue';

// ...etc

Then I got this curious little error when trying to load pages that used this component:

runtime-core.esm-bundler-DVzoSkgu.js:16 TypeError: Cannot read properties of null (reading 'isCE')
    at zc (__federation_shared_…98ybS7y.js:13:21835)
    at Proxy.<anonymous> (__federation_expose_…n-BaxrQR96.js:1:819)
    at Ct (runtime-core.esm-bun…DVzoSkgu.js:16:5697)
    at me.x [as fn] (runtime-core.esm-bun…DVzoSkgu.js:24:5386)
    at me.run (runtime-core.esm-bun…-DVzoSkgu.js:9:1517)
    at U.a.update (runtime-core.esm-bun…DVzoSkgu.js:24:5674)
    at U (runtime-core.esm-bun…DVzoSkgu.js:24:5701)
    at on (runtime-core.esm-bun…DVzoSkgu.js:24:4404)
    at X (runtime-core.esm-bun…DVzoSkgu.js:24:4200)
    at M (runtime-core.esm-bun…DVzoSkgu.js:24:1156)

I was kind of stumped. After some Googling around, I found that this error is because there are multiple instances of Vue being loaded.

A screen shot of a comment on a closed issue on the Vue repository: That's because you have two distinct copies of the Vue package being used, one in each package.  Vue uses a global singleton to track the current rendering instance - having more than one copy included will inevitably lead to such issues.  Solution: configure project in a way that Al packages use that same package.  In yarn workspaces, this would work fine because Vue would be hoistws to the root's node_modules.

But I followed the docs and put the "shared" option in the plugin on both the remote and host app?

// vite-external.config.js (remote) AND vite-admin.config.js (host)
// ...
federation({
    // ...
    shared: ['vue'],
}),
// ...

Isn't that enough? Well, (spoiler alert?) it's not.

The Vite config for the remote app loading in external modules was specifying an input that imported other Vue applications on the page that weren't pointing to the shared Vue instance that the federation plugin was generating. I did some more Googling and found issues like this one, which made me think this whole approach just wasn't going to work.

A screen shot of a comment on a open issue in the vite-plugin-federation repository: Thanks @ruleeeer and @robertovg . Your solution and examples worked for me.  But it doesn't work with the latest versions of vite-plugin-federation . Do you know have any solution for this issue with the latest versions ?

Pretty demoralizing, tbh. Why wouldn't this work? Would maybe trying to do this with Webpack module federation work? I briefly went down a dark road trying to get a Webpack config setup for just my federated modules. I won't share that, it's quite painful and with Vite being ESM based exclusively and Webpack being CommonJS, this just wasn't going to work.

But trying to extract just the shared components gave me an idea. Presently, I was trying to use a single Vite config file for both the SPAs that load the navigation elements as well as the federated modules. Maybe that was where the problem of multiple instances of Vue being referenced was coming from... (spoiler alert: it was).

The Final Solu... uh, Resolution

So, I made yet another Vite config for just the federated modules. But since the build output of this wasn't going to be used in the web portal app at all, it didn't need to specify an input (no JS file would be importing it in this repository) or use the Laravel Vite plugin. This means I needed to finagle the Vite config so the build output ended up in the public directory in a separate build subdirectory. Here's what I ended up with:

// vite-external.config.js (remote) final version

import { defineConfig } from 'vite';
import federation from '@originjs/vite-plugin-federation';
import topLevelAwait from 'vite-plugin-top-level-await';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
    plugins: [
        vue({
            template: {
                transformAssetUrls: {
                    base: null,
                    includeAbsolute: false,
                },
            },
        }),
        topLevelAwait({
            // The export name of top-level await promise for each chunk module
            promiseExportName: '__tla',
            // The function to generate import names of top-level await promise in each chunk module
            promiseImportName: (i) => `__tla_${i}`,
        }),
        federation({
            name: 'shared-components',
            filename: 'remoteEntry.js',
            exposes: {
                './FormSection': './resources/js/Components/Form/FormSection.vue',
                './Left': './resources/js/Components/Form/Left.vue',
                './Right': './resources/js/Components/Form/Right.vue',
            },
            shared: ['vue'],
        }),
    ],
    base: '/public/',
    publicDir: false,
    build: {
        manifest: 'manifest.json',
        outDir: 'public/build-external',
        rollupOptions: {
            input: [],
        },
    },
});

And it worked! By the end of it, I was running three Vite builds (using vite build -c vite.config.js as many times as you have separate configs), which leaves me just a little unsatisfied, but a perfectly workable tradeoff. I only needed to make one more tweak to the host app Vite config: the Laravel Vite plugin config needs to come after the module federation plugin. If it's specified before, the shared Vue instance won't be imported correctly. It has something to do with how the Laravel Vite plugin does some weirdness with a placeholder... or something.

// vite-admin.config.js (host) final version
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
import federation from '@originjs/vite-plugin-federation';
import topLevelAwait from 'vite-plugin-top-level-await';

export default defineConfig({
  plugins: [
    vue({
      template: {
        transformAssetUrls: {
          base: null,
          includeAbsolute: false,
        },
      },
    }),
    topLevelAwait({
      // The export name of top-level await promise for each chunk module
      promiseExportName: "__tla",
      // The function to generate import names of top-level await promise in each chunk module
      promiseImportName: i => `__tla_${i}`
    }),
    federation({
      name: 'host-app',
      remotes: {
        'shared-components': 'https://remote-app.com/build-external/assets/remoteEntry.js',
      },
      shared: ['vue'],
    }),
    // must be after federation plugin config... it messes with the vite module specifier for the shared Vue instance
    laravel({
      input: 'resources/js/admin/index.js',
      buildDirectory: 'build-admin',
    }),
  ],
});

Conclusion

So, that's it. Being able to share Vue components between applications at runtime is a huge convenience and I'm super happy I got it to work. I hope this helps you or you at least found it interesting. Cheers!