Skip to content

Upgrade to MF v2, and expose UI #6

@elliotBraem

Description

@elliotBraem

Complete Migration Plan

Phase 1: Backend Module Federation v2 Migration

1.1 Update Plugin Template rspack.config.cjs

File: plugins/_template/rspack.config.cjs

const path = require("node:path");
const { ModuleFederationPlugin } = require('@module-federation/enhanced/rspack'); // v2

const pkg = require("./package.json");
const { getNormalizedRemoteName } = require("every-plugin/normalize");
const everyPluginPkg = require("every-plugin/package.json");

function getPluginInfo() {
  return {
    name: pkg.name,
    version: pkg.version,
    normalizedName: getNormalizedRemoteName(pkg.name),
    dependencies: pkg.dependencies || {},
    peerDependencies: pkg.peerDependencies || {},
  };
}

const pluginInfo = getPluginInfo();

module.exports = {
  entry: "./src/index",
  mode: process.env.NODE_ENV === "development" ? "development" : "production",
  target: "async-node",
  devtool: "source-map",
  
  output: {
    uniqueName: pluginInfo.normalizedName,
    publicPath: "auto",
    path: path.resolve(__dirname, "dist"),
    clean: true,
    library: { type: "commonjs-module" },
  },
  
  devServer: {
    static: path.join(__dirname, "dist"),
    hot: true,
    port: 3999,
    devMiddleware: { writeToDisk: true },
  },
  
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: "builtin:swc-loader",
        exclude: /node_modules/,
      },
    ],
  },
  
  resolve: {
    extensions: [".tsx", ".ts", ".js"],
  },
  
  plugins: [
    new ModuleFederationPlugin({
      name: pluginInfo.normalizedName,
      filename: "remoteEntry.js",
      
      // v2: Runtime plugins
      runtimePlugins: [
        require.resolve("@module-federation/node/runtimePlugin"),
      ],
      
      library: { type: "commonjs-module" },
      
      exposes: {
        "./plugin": "./src/index.ts",
      },
      
      // v2: Manifest generation
      manifest: {
        fileName: "mf-manifest.json",
        additionalData: async ({ manifest }) => ({
          ...manifest,
          type: "backend",
          target: "node",
          buildTime: new Date().toISOString(),
          gitCommit: process.env.GIT_COMMIT,
        }),
      },
      
      // v2: TypeScript types
      dts: {
        tsConfigPath: "./tsconfig.json",
        generateTypes: {
          compiledTypesFolder: "compiled-types",
          typesFolder: "@mf-types",
          deleteTypesFolder: true,
          extractThirdParty: false,
          extractRemoteTypes: false, // Backend doesn't need remote types
        },
      },
      
      // v2: Share strategy
      shareStrategy: "loaded-first",
      
      shared: {
        "every-plugin": {
          version: everyPluginPkg.version,
          singleton: true,
          requiredVersion: everyPluginPkg.version,
          strictVersion: false,
          eager: false,
        },
        effect: {
          version: everyPluginPkg.dependencies.effect,
          singleton: true,
          requiredVersion: everyPluginPkg.dependencies.effect,
          strictVersion: false,
          eager: false,
        },
        zod: {
          version: everyPluginPkg.dependencies.zod,
          singleton: true,
          requiredVersion: everyPluginPkg.dependencies.zod,
          strictVersion: false,
          eager: false,
        },
        "@orpc/contract": {
          version: everyPluginPkg.dependencies["@orpc/contract"],
          singleton: true,
          requiredVersion: everyPluginPkg.dependencies["@orpc/contract"],
          strictVersion: false,
          eager: false,
        },
        "@orpc/server": {
          version: everyPluginPkg.dependencies["@orpc/server"],
          singleton: true,
          requiredVersion: everyPluginPkg.dependencies["@orpc/server"],
          strictVersion: false,
          eager: false,
        },
      },
    }),
  ],
};

1.2 Update normalizeRemoteUrl (Manifest Support)

File: packages/core/src/runtime/index.ts

/**
 * Normalizes a remote URL to ensure it points to mf-manifest.json (v2) or remoteEntry.js (v1)
 */
function normalizeRemoteUrl(url: string): string {
  // If already points to a file, keep it
  if (url.endsWith('.json') || url.endsWith('.js')) return url;
  
  // v2: Default to manifest
  return `${url.endsWith('/') ? url.slice(0, -1) : url}/mf-manifest.json`;
}

Phase 2: Client Runtime for Browser UI

2.1 Create Lightweight Client Runtime

File: packages/core/src/runtime/client.ts

import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { RouterClient } from "@orpc/server";
import { init, loadRemote } from "@module-federation/enhanced/runtime";
import type { PluginRegistry, RegisteredPlugins } from "../types";

export interface ClientRuntimeConfig {
  registry: {
    [K in keyof RegisteredPlugins]: {
      remoteUrl: string;  // Backend manifest
      version: string;
      ui?: {
        remoteUrl: string;  // Frontend manifest
        version: string;
      };
    };
  };
  rpcEndpoint: string;  // Base URL where plugin routers are mounted
}

export interface UIPluginComponents<K extends keyof RegisteredPlugins> {
  // Components will be discovered from manifest
  [componentName: string]: React.ComponentType<any>;
}

export interface UsePluginUIResult<K extends keyof RegisteredPlugins> {
  components: UIPluginComponents<K>;
  rpcClient: RouterClient<RegisteredPlugins[K]["binding"]["contract"]>;
  metadata: {
    id: string;
    version: string;
    manifest: any;
  };
}

export class ClientRuntime {
  private mfInstance: ReturnType<typeof init>;
  private componentCache = new Map<string, any>();
  private rpcClientCache = new Map<string, any>();
  
  constructor(private config: ClientRuntimeConfig) {
    // Initialize Module Federation for browser
    this.mfInstance = init({
      name: "ui_host",
      remotes: Object.entries(config.registry)
        .filter(([_, entry]) => entry.ui) 
        .map(([name, entry]) => ({
          name: `${name}_ui`,
          entry: entry.ui!.remoteUrl,
          alias: name,
        })),
      shared: {
        react: { singleton: true, requiredVersion: "^18.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^18.0.0" },
      },
    });
  }
  
  async usePlugin<K extends keyof RegisteredPlugins & string>(
    pluginId: K
  ): Promise<UsePluginUIResult<K>> {
    const entry = this.config.registry[pluginId];
    
    if (!entry.ui) {
      throw new Error(`Plugin ${pluginId} does not expose UI components`);
    }
    
    // Fetch manifest to discover components
    const manifestResponse = await fetch(entry.ui.remoteUrl);
    const manifest = await manifestResponse.json();
    
    const componentMeta = manifest.components || {};
    
    // Create lazy loaders for all components
    const components: any = {};
    
    for (const [name, meta] of Object.entries(componentMeta) as [string, any][]) {
      const componentKey = `${pluginId}/${name}`;
      
      if (!this.componentCache.has(componentKey)) {
        // Use React.lazy pattern
        const LazyComponent = React.lazy(() =>
          loadRemote<{ default: React.ComponentType<any> }>(`${pluginId}/${name}`)
        );
        this.componentCache.set(componentKey, LazyComponent);
      }
      
      components[name] = this.componentCache.get(componentKey);
    }
    
    // Create RPC client for backend communication
    const rpcPath = `${this.config.rpcEndpoint}/${pluginId}`;
    
    if (!this.rpcClientCache.has(pluginId)) {
      const link = new RPCLink({
        url: rpcPath,
        fetch: globalThis.fetch,
      });
      
      const client = createORPCClient(link);
      this.rpcClientCache.set(pluginId, client);
    }
    
    return {
      components,
      rpcClient: this.rpcClientCache.get(pluginId)!,
      metadata: {
        id: pluginId,
        version: entry.ui.version,
        manifest,
      },
    };
  }
}

/**
 * Create a lightweight runtime for browser UI
 * Does NOT use Effect/ManagedRuntime - pure Module Federation
 */
export function createClientRuntime(
  config: ClientRuntimeConfig
): ClientRuntime {
  return new ClientRuntime(config);
}

2.2 Export from main package

File: packages/core/src/index.ts

// Add to exports
export { createClientRuntime, type ClientRuntimeConfig, type UsePluginUIResult } from "./runtime/client";

File: packages/core/package.json - Add export

{
  "exports": {
    "./runtime/client": {
      "types": "./dist/runtime/client.d.ts",
      "default": "./dist/runtime/client.js"
    }
  }
}

Phase 3: Plugin UI Build Structure

3.1 Create UI Template Structure

New Directory: plugins/_template/ui/

plugins/_template/ui/
├── src/
│   ├── main.tsx
│   ├── components/
│   │   ├── Dashboard.tsx
│   │   └── Settings.tsx
│   └── lib/
│       └── rpc-client.ts
├── rspack.config.js
├── package.json
└── tsconfig.json

3.2 UI Build Config

File: plugins/_template/ui/rspack.config.js

const { ModuleFederationPlugin } = require('@module-federation/enhanced/rspack');
const path = require('path');

const parentPkg = require('../package.json');

module.exports = {
  target: 'web',
  entry: './src/main.tsx',
  
  output: {
    path: path.resolve(__dirname, 'dist'),
    publicPath: 'http://localhost:3100/',  // Different port for UI
    clean: true,
  },
  
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'builtin:swc-loader',
        exclude: /node_modules/,
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
  
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
  
  plugins: [
    new ModuleFederationPlugin({
      name: `${parentPkg.name.replace(/[@\/]/g, '_')}_ui`,
      
      exposes: {
        './Dashboard': './src/components/Dashboard',
        './Settings': './src/components/Settings',
      },
      
      manifest: {
        fileName: 'mf-manifest.json',
        additionalData: async ({ manifest }) => ({
          ...manifest,
          type: 'frontend',
          target: 'web',
          framework: 'react',
          components: {
            Dashboard: {
              path: './Dashboard',
              description: 'Main dashboard view',
            },
            Settings: {
              path: './Settings',
              description: 'Plugin settings panel',
            },
          },
        }),
      },
      
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
  
  devServer: {
    port: 3100,
    hot: true,
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
};

3.3 Update Parent Scripts

File: plugins/_template/package.json

{
  "scripts": {
    "dev": "concurrently \"npm run dev:backend\" \"npm run dev:ui\"",
    "dev:backend": "rspack serve --config rspack.config.cjs --port 3999",
    "dev:ui": "cd ui && rspack serve --port 3100",
    
    "build": "npm run build:backend && npm run build:ui",
    "build:backend": "rspack build --config rspack.config.cjs",
    "build:ui": "cd ui && rspack build"
  },
  "devDependencies": {
    "concurrently": "^9.0.0"
  }
}

Phase 4: Registry & Type Updates

4.1 Enhanced Registry Type

File: packages/core/src/types.ts

export interface PluginRegistryEntry {
  remoteUrl: string;  // Backend manifest URL
  version: string;
  ui?: {
    remoteUrl: string;  // Frontend manifest URL  
    version: string;
  };
}

export type PluginRegistry = Record<string, PluginRegistryEntry>;

4.2 Update createPluginRuntime

File: packages/core/src/runtime/index.ts

function normalizeRemoteUrl(url: string): string {
  if (url.endsWith('.json') || url.endsWith('.js')) return url;
  // v2: Default to manifest
  return `${url.endsWith('/') ? url.slice(0, -1) : url}/mf-manifest.json`;
}

export function createPluginRuntime(
  config: PluginRuntimeConfig
): PluginRuntime {
  const secrets = config.secrets || {};

  // Normalize backend URLs only
  const normalizedRegistry = Object.fromEntries(
    Object.entries(config.registry).map(([pluginId, entry]) => [
      pluginId,
      {
        ...entry,
        remoteUrl: normalizeRemoteUrl(entry.remoteUrl),
        // Keep UI remoteUrl as-is if present
        ...(entry.ui && {
          ui: {
            ...entry.ui,
            remoteUrl: normalizeRemoteUrl(entry.ui.remoteUrl),
          },
        }),
      },
    ])
  ) as PluginRegistry;

  const layer = PluginService.Live(normalizedRegistry, secrets);
  const runtime = Managed

Runtime.make(layer);

  return new PluginRuntime(runtime, normalizedRegistry);
}

Phase 5: Example Implementation

5.1 Update Example Registry

File: examples/curatedotfun/plugins.ts

import type GopherAIPlugin from "@curatedotfun/gopher-ai";
import { createPluginRuntime } from "every-plugin/runtime";

declare module "every-plugin" {
  interface RegisteredPlugins {
    "@curatedotfun/gopher-ai": typeof GopherAIPlugin;
  }
}

export const runtime = createPluginRuntime({
  registry: {
    "@curatedotfun/gopher-ai": {
      // v2: Manifest URL instead of remoteEntry.js
      remoteUrl: "https://elliot-braem-159-curatedotfun-gopher-ai-every-plu-bf7adf22c-ze.zephyrcloud.app/mf-manifest.json",
      version: "1.0.0",
      
      // NEW: UI manifest (if plugin has UI)
      ui: {
        remoteUrl: "https://elliot-braem-159-curatedotfun-gopher-ai-ui-bf7adf22c-ze.zephyrcloud.app/mf-manifest.json",
        version: "1.0.0"
      }
    }
  },
  secrets: {
    GOPHERAI_API_KEY: Bun.env.GOPHERAI_API_KEY || "your-masa-api-key-here"
  }
});

// Backend plugin initialization (unchanged)
const gopherAi = await runtime.usePlugin(
  "@curatedotfun/gopher-ai",
  {
    secrets: { apiKey: "{{GOPHERAI_API_KEY}}" },
    variables: { baseUrl: "https://data.gopher-ai.com/api/v1", timeout: 30000 }
  }
);

export const plugins = {
  gopherAi
};

5.2 Create Frontend Example

New File: examples/curatedotfun-ui/app.tsx

import { createClientRuntime } from "every-plugin/runtime/client";
import { Suspense } from "react";

const clientRuntime = createClientRuntime({
  registry: {
    "@curatedotfun/gopher-ai": {
      remoteUrl: "https://...backend.../mf-manifest.json",
      version: "1.0.0",
      ui: {
        remoteUrl: "https://...frontend.../mf-manifest.json",
        version: "1.0.0"
      }
    }
  },
  rpcEndpoint: "http://localhost:3000/api"  // Where backend router is mounted
});

export function App() {
  const [plugin, setPlugin] = useState<any>(null);
  
  useEffect(() => {
    clientRuntime.usePlugin("@curatedotfun/gopher-ai").then(setPlugin);
  }, []);
  
  if (!plugin) return <div>Loading...</div>;
  
  const { components, rpcClient } = plugin;
  const Dashboard = components.Dashboard;
  
  return (
    <Suspense fallback={<div>Loading component...</div>}>
      <Dashboard rpcClient={rpcClient} />
    </Suspense>
  );
}

Phase 6: Testing Updates

6.1 Update Test Fixtures

File: packages/core/tests/fixtures/test-plugin/rspack.config.cjs

Apply same v2 changes as template (manifest, dts, shareStrategy, etc.)

6.2 Add Client Runtime Tests

New File: packages/core/tests/unit/client-runtime.test.ts

import { createClientRuntime } from "every-plugin/runtime/client";
import { beforeAll, describe, expect, it } from "vitest";

describe("ClientRuntime", () => {
  it("should initialize without Effect dependencies", () => {
    const runtime = createClientRuntime({
      registry: {
        "test-plugin": {
          remoteUrl: "http://localhost:3999/mf-manifest.json",
          version: "1.0.0",
          ui: {
            remoteUrl: "http://localhost:3100/mf-manifest.json",
            version: "1.0.0"
          }
        }
      },
      rpcEndpoint: "http://localhost:3000/api"
    });
    
    expect(runtime).toBeDefined();
  });
  
  // More tests for component loading, RPC client creation, etc.
});

Phase 7: Documentation Updates

7.1 Update LLM.txt

Add section on UI builds and client runtime

7.2 Update docs/index.mdx

Add UI component section

Conflicts Analysis

No major conflicts detected, but considerations:

  1. Module Federation services remain Node-only - Client runtime bypasses them entirely
  2. Shared packages are correct - Client doesn't need @orpc/server or Effect services
  3. Cache separation - Client runtime has its own caches, independent of server
  4. Port management - Backend (3999) and UI (3100) use different ports

Migration Checklist

  • Update all plugin rspack configs to v2
  • Change remoteUrl to manifest URLs in registries
  • Add manifest generation config
  • Add dts config for TypeScript
  • Add shareStrategy: "loaded-first"
  • Create ui/ directory in plugins that need UI
  • Implement client runtime (lightweight, no Effect)
  • Add UI build scripts
  • Update examples
  • Add client runtime tests
  • Update documentation

This plan maintains backward compatibility while adding v2 features incrementally!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions