-
Notifications
You must be signed in to change notification settings - Fork 4
Description
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:
- Module Federation services remain Node-only - Client runtime bypasses them entirely
- Shared packages are correct - Client doesn't need @orpc/server or Effect services
- Cache separation - Client runtime has its own caches, independent of server
- 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!