Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ The page consumes it like any other Nuxt component:
</template>
```

### 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.
Expand Down
60 changes: 26 additions & 34 deletions apps/host/app/app.vue
Original file line number Diff line number Diff line change
@@ -1,47 +1,39 @@
<script setup lang="ts">
import HostCard from "./components/HostCard.vue";
import HostSsrComponent from "./components/HostSsrComponent.vue";
</script>

<template>
<main class="examples">
<div class="host-column">
<HostCard />
</div>
<div class="component-grid">
<HostSsrComponent />
<Suspense>
<RemoteWidget />
<template #fallback>
<div>Loading remote widget...</div>
</template>
</Suspense>
<Suspense>
<RemoteCounter />
<template #fallback>
<div>Loading remote counter...</div>
</template>
</Suspense>
</div>
</main>
<div class="shell">
<header>
<strong>Nuxt MF host</strong>
<nav>
<NuxtLink to="/">Components</NuxtLink>
<NuxtLink to="/bridge">Bridge app</NuxtLink>
<NuxtLink to="/bridge/detail">Bridge detail</NuxtLink>
</nav>
</header>
<NuxtPage />
</div>
</template>

<style>
body {
margin: 0;
font-family: sans-serif;
}

.examples {
display: block;
}

.host-column {
float: left;
.shell header {
display: flex;
gap: 1.25rem;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e2e8f0;
}

.component-grid {
.shell nav {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.shell a {
color: #2563eb;
text-decoration: none;
}
.shell a.router-link-active {
font-weight: 600;
}
</style>
44 changes: 44 additions & 0 deletions apps/host/app/components/BridgeRemotePage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script setup lang="ts">
/**
* Host catch-all for the remote Bridge app (`remote/bridge/export-app`).
*
* Route path uses Vue Router's `:pathMatch(.*)*` so current bridge-vue3
* basename auto-detect works. Prefer explicit `basename: '/bridge'` once
* module-federation/core#4984 is published.
*/
import { createRemoteAppComponent } from "@module-federation/bridge-vue3";

const RemoteBridgeApp = createRemoteAppComponent({
loader: () => import("remote/bridge/export-app"),
// After module-federation/core#4984, prefer: basename: '/bridge'
asyncComponentOptions: {
suspensible: false,
},
});
</script>

<template>
<ClientOnly>
<div class="bridge-host">
<p class="hint">
Application-level Bridge remote (client island). Component federation on
<NuxtLink to="/">/</NuxtLink> is unchanged.
</p>
<component :is="RemoteBridgeApp" />
</div>
<template #fallback>
<p class="hint">Loading Bridge remote…</p>
</template>
</ClientOnly>
</template>

<style scoped>
.bridge-host {
padding: 1rem;
}
.hint {
opacity: 0.8;
font-size: 0.9rem;
margin-bottom: 1rem;
}
</style>
40 changes: 40 additions & 0 deletions apps/host/app/pages/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<script setup lang="ts">
import HostCard from "../components/HostCard.vue";
import HostSsrComponent from "../components/HostSsrComponent.vue";
</script>

<template>
<main class="examples">
<div class="host-column">
<HostCard />
</div>
<div class="component-grid">
<HostSsrComponent />
<Suspense>
<RemoteWidget />
<template #fallback>
<div>Loading remote widget...</div>
</template>
</Suspense>
<Suspense>
<RemoteCounter />
<template #fallback>
<div>Loading remote counter...</div>
</template>
</Suspense>
</div>
</main>
</template>

<style scoped>
.examples {
display: block;
}
.host-column {
float: left;
}
.component-grid {
display: flex;
flex-wrap: wrap;
}
</style>
8 changes: 8 additions & 0 deletions apps/host/app/types/remote.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => void | Promise<void>;
destroy: (info: { dom: HTMLElement }) => void;
};
export default createProvider;
}
12 changes: 12 additions & 0 deletions apps/host/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
1 change: 1 addition & 0 deletions apps/host/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion apps/remote/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 `<RemoteExample />`.

### 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:
Expand All @@ -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).
27 changes: 27 additions & 0 deletions apps/remote/app/bridge/App.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<template>
<div class="bridge-app">
<h2>Bridge remote app</h2>
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/detail">Detail</RouterLink>
</nav>
<RouterView />
</div>
</template>

<style scoped>
.bridge-app {
border: 1px solid #334155;
border-radius: 8px;
padding: 1rem;
max-width: 28rem;
}
nav {
display: flex;
gap: 0.75rem;
margin: 0.75rem 0;
}
a {
color: #2563eb;
}
</style>
5 changes: 5 additions & 0 deletions apps/remote/app/bridge/pages/Detail.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<section>
<p>Bridge detail route (host URL should keep the /bridge prefix).</p>
</section>
</template>
5 changes: 5 additions & 0 deletions apps/remote/app/bridge/pages/Home.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<section>
<p>Bridge home route. Full remote routing under the host basename.</p>
</section>
</template>
17 changes: 17 additions & 0 deletions apps/remote/app/bridge/router.ts
Original file line number Diff line number Diff line change
@@ -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 },
],
});
}
14 changes: 14 additions & 0 deletions apps/remote/app/export-app.ts
Original file line number Diff line number Diff line change
@@ -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(),
}),
});
5 changes: 5 additions & 0 deletions apps/remote/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions apps/remote/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions e2e/nuxt.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(/\/$/);
});
Loading
Loading