diff --git a/src/mf/remote-loader.test.ts b/src/mf/remote-loader.test.ts new file mode 100644 index 00000000..d93c0ee5 --- /dev/null +++ b/src/mf/remote-loader.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the runtime package so tests don't need a real remote. +vi.mock("@module-federation/enhanced/runtime", () => { + return { + init: vi.fn(), + loadRemote: vi.fn(), + registerRemotes: vi.fn(), + }; +}); + +import { init, loadRemote, registerRemotes } from "@module-federation/enhanced/runtime"; + +describe("remote-loader", () => { + beforeEach(async () => { + const mod = await import("./remote-loader.js"); + mod.__resetRemoteLoaderForTests(); + vi.mocked(init).mockClear(); + vi.mocked(loadRemote).mockClear(); + vi.mocked(registerRemotes).mockClear(); + }); + + afterEach(() => { + vi.resetModules(); + }); + + it("registerRemote calls init once for the first remote, then registerRemotes for subsequent", async () => { + const { registerRemote } = await import("./remote-loader.js"); + registerRemote({ name: "customer-ui", entry: "https://host.example/mf-manifest.json" }); + expect(init).toHaveBeenCalledTimes(1); + expect(init).toHaveBeenCalledWith({ + name: "aeroflot_flights_host", + remotes: [{ name: "customer-ui", entry: "https://host.example/mf-manifest.json" }], + }); + + registerRemote({ name: "customer-analytics", entry: "https://host.example/analytics/mf-manifest.json" }); + expect(init).toHaveBeenCalledTimes(1); // still only once + expect(registerRemotes).toHaveBeenCalledTimes(1); + expect(registerRemotes).toHaveBeenCalledWith([ + { name: "customer-analytics", entry: "https://host.example/analytics/mf-manifest.json" }, + ]); + }); + + it("loadRemoteModule delegates to loadRemote with the concatenated key", async () => { + const fakeModule = { default: "hello" }; + vi.mocked(loadRemote).mockResolvedValueOnce(fakeModule); + + const { loadRemoteModule } = await import("./remote-loader.js"); + const result = await loadRemoteModule<{ default: string }>({ + name: "customer-ui", + module: "./Header", + }); + + expect(loadRemote).toHaveBeenCalledWith("customer-ui/Header"); + expect(result).toBe(fakeModule); + }); + + it("loadRemoteModule rejects with a typed error if the module returns null/undefined", async () => { + vi.mocked(loadRemote).mockResolvedValueOnce(null); + const { loadRemoteModule } = await import("./remote-loader.js"); + await expect( + loadRemoteModule({ name: "customer-ui", module: "./NonExistent" }), + ).rejects.toThrow(/customer-ui\/NonExistent/); + }); + + it("loadRemoteModule propagates errors from loadRemote", async () => { + vi.mocked(loadRemote).mockRejectedValueOnce(new Error("boom")); + const { loadRemoteModule } = await import("./remote-loader.js"); + await expect( + loadRemoteModule({ name: "customer-ui", module: "./Broken" }), + ).rejects.toThrow("boom"); + }); +}); diff --git a/src/mf/remote-loader.ts b/src/mf/remote-loader.ts new file mode 100644 index 00000000..b93ad0e9 --- /dev/null +++ b/src/mf/remote-loader.ts @@ -0,0 +1,58 @@ +import { + init, + loadRemote, + registerRemotes, +} from "@module-federation/enhanced/runtime"; + +export interface RemoteModuleRef { + name: string; + module: string; + /** Phantom marker to preserve the generic parameter through the type system. */ + readonly __type?: T; +} + +export interface RemoteEntry { + name: string; + entry: string; +} + +let initialized = false; +const HOST_NAME = "aeroflot_flights_host" as const; + +/** + * Registers a remote with the MF runtime. On the first call, this also calls + * `init()` to bootstrap the MF runtime with the host identity. Subsequent + * calls use `registerRemotes()` to add remotes without re-initializing. + */ +export function registerRemote(entry: RemoteEntry): void { + if (!initialized) { + init({ + name: HOST_NAME, + remotes: [entry], + }); + initialized = true; + return; + } + registerRemotes([entry]); +} + +/** + * Loads an exposed module from a previously registered remote. The `name` + + * `module` fields are concatenated into MF's `remote/module` lookup key. + * + * Throws if the runtime returns a null/undefined module, which usually means + * the remote manifest does not actually expose the requested path. + */ +export async function loadRemoteModule(ref: RemoteModuleRef): Promise { + const key = `${ref.name}/${ref.module.replace(/^\.\//, "")}`; + const result = await loadRemote(key); + if (result === null || result === undefined) { + throw new Error(`Remote module ${key} loaded as null/undefined`); + } + return result; +} + +/** Test-only: resets the one-shot init flag. */ +export function __resetRemoteLoaderForTests(): void { + initialized = false; +}