Introduction #
This post will be a continuation of my series on MFEs (Micro Frontends), if you haven't read the last 2 then I would recommend doing so before this one.
I'll discuss setting up a container app, in this context container does not relate to docker containers, but in fact the MFE which contains the upstream MFEs. This application will likely be a thin window into the other MFEs. It could contain navigational components for switching between apps, or potentially the navigation can be its own MFE that the container provides a window to.
In my example app [0] I chose to embed the nav in the container app just for simplicity’s sake.
Prerequisites #
- docker
- docker-compose
- go (I'm using 1.16.4)
You will also need the resulting project from my last post, if you don't have that anymore, then I have pushed the codebase to GitHub [1].
Creating the container app #
For brevity, I'm going to refer back to my last post when directing you to create a new MFE within the project. Use the “Hello” example, and change all references of “hello” to be “container”.
The result should give you a project that looks roughly like the following:
docker
├── docker-compose.yaml
├── Dockerfile
├── moxy-config.toml
└── variables.env
src
├── hello
├── goodbye
└── container
Route via the proxy #
The container app in our case will be the default route for all traffic. So, first we need to handle all traffic in our app by setting the path prefix to empty:
// src/container/app/app.go
package app
import (
"container/types"
"log"
"net/http"
"github.com/BurntSushi/toml"
"github.com/gorilla/mux"
)
func Run(env string) {
var c types.Config
config := "local.toml"
if env != "" {
config = env + ".toml"
}
if _, err := toml.DecodeFile(config, &c); err != nil {
log.Fatal(err)
}
r := mux.NewRouter()
r.PathPrefix("").HandlerFunc(HandleRequest)
err := http.ListenAndServe(c.Port, r)
log.Fatal(err)
}
Then we need to update our docker-compose.yaml
to run the container app:
# docker/docker-compose.yaml
version: "3"
services:
hello:
build: .
env_file:
- 'variables.env'
volumes:
- ../src/hello/:/app
goodbye:
build: .
env_file:
- 'variables.env'
volumes:
- ../src/goodbye/:/app
container:
build: .
env_file:
- 'variables.env'
volumes:
- ../src/container/:/app
proxy:
image: joedavidson1802/moxy:v0.2.1
volumes:
- ./moxy-config.toml:/app/defaults.toml
ports:
- "80:80"
And then to expose the container we need to update our Moxy config to fallback on our container app, default_upstream
serves this purpose:
# docker/moxy-config.toml
moxy_path = "/moxy"
default_upstream = "http://container"
[[upstream]]
path_prefix = "/apps/hello"
host = "http://hello"
[[upstream]]
path_prefix = "/apps/goodbye"
host = "http://goodbye"
With docker-compose up
you should be able to run and see the container page at http://localhost, while still seeing our apps at http://localhost/apps/hello and http://localhost/apps/goodbye.
Implement Turbo #
For orchestrating the MFEs I use Turbo [2], it's designed to make SSR apps faster by not requiring a full page reload. To import turbo we need to edit the container template file.
Update the layout #
<!-- This snippet will not compile as is, html comments are not currently supported in templ -->
<!-- src/container/templates/container.templ -->
{% package templates %}
{% templ ContainerTemplate(url string) %}
<html>
<head>
<script type="module">{%! Raw("import hotwiredTurbo from 'https://cdn.skypack.dev/@hotwired/turbo';") %}</script>
</head>
<body>
<nav>
<a href="/hello" target="_top">{%= "Hello" %}</a>
<a href="/goodbye" target="_top">{%= "Goodbye" %}</a>
</nav>
<turbo-frame id="app-container" src={%= url %}>{%= "Select an app" %}</turbo-frame>
</body>
</html>
{% endtempl %}
First, is the module import, it just loads the turbo library (I'm using a Raw function to avoid my JS being escaped by templ):
<script type="module">{%! Raw("import hotwiredTurbo from 'https://cdn.skypack.dev/@hotwired/turbo';") %}</script>
The Raw function:
// src/container/templates/raw.go
package templates
import (
"context"
"io"
"github.com/a-h/templ"
)
func Raw(text string) (t templ.Component) {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
_, err = io.WriteString(w, text)
return err
})
}
Next, is the nav
element, with a link to each of our upstream MFEs. Notice that I'm excluding the /apps
prefix that the upstream MFEs require. This is so that the container app intercepts the page-loads rather than the upstream apps being returned on their own.
<nav>
<a href="/hello" target="_top">{%= "Hello" %}</a>
<a href="/goodbye" target="_top">{%= "Goodbye" %}</a>
</nav>
At the core is the templ-frame
this is an element that allows us to render in content from other URLs. Frames require an id
, and I'm using the src
attribute which tells Turbo where to load the contents from, in this case it's parameterized. And I provide some default text in case there is no URL provided or the frame hasn't loaded yet.
<turbo-frame id="app-container" src={%= url %}>{%= "Select an app" %}</turbo-frame>
One option would have been to call the apps by using a data-turbo-frame
on the link, this would cause Turbo to intercept the call, avoiding having to go to the container app every time we make a request. But currently this doesn't support updating browser URLs or history (a feature in the works), this would look like the following. Notice we do provide the /apps
prefix here as turbo would call our MFEs and replace the contents of app-container
with the new data.
<nav>
<a href="/apps/hello" data-turbo-frame="app-container">{%= "Hello" %}</a>
<a href="/apps/goodbye" data-turbo-frame="app-container">{%= "Goodbye" %}</a>
</nav>
<turbo-frame id="app-container" src={%= url %}>{%= "Select an app" %}</turbo-frame>
Provide the navigation URL #
In the handler function we now need to pass the request URL down to the template, adding the /apps
prefix so that the turbo-frame
has an src
attribute that is routed to the MFE. We also need to provide an empty string if a request is to the root, to avoid recursive calls.
// src/container/app/container.go
package app
import (
"container/templates"
"net/http"
)
func HandleRequest(w http.ResponseWriter, r *http.Request) {
uri := r.RequestURI
if uri == "/" || uri == "" {
uri = ""
} else {
uri = "/apps" + uri
}
t := templates.ContainerTemplate(uri)
err := t.Render(r.Context(), w)
if err != nil {
http.Error(w, "failed to render", http.StatusInternalServerError)
}
}
The request journey for our app if someone clicked the “Hello” nav item:
- Initial request: http://localhost/hello → Moxy → http://container/hello
- Response:
<turbo-frame id="app-container" src="/apps/hello">"Select an app"</turbo-frame>
- Turbo requests the frame: http://localhost/apps/hello → Moxy → http://hello/apps/hello
- Turbo renders the response
Try it out #
We're now half-way there, the container is built. If you run the app with docker-compose build
and docker-compose up
and navigate to http://localhost/ you should see the default page:
If you select an option however, or navigate directly to http://localhost/hello you'll notice that the response is not what you'd expect. Nothing happens. If you check the console or network inspector you should see the browser requesting our /apps/hello
url, and responding with a 200, but it doesn't get rendered.
Turbo Frame responses #
We are missing a step, Turbo responses must container a turbo-frame
with a matching id
so that Turbo knows what to render and where.
So let's make this change to both apps:
<!-- This snippet will not compile as is, html comments are not currently supported in templ -->
<!-- src/goodbye/templates/goodbye.templ -->
{% package templates %}
{% templ GoodbyeTemplate(message string) %}
<turbo-frame id="app-container">
<p>{%= message %}</p>
</turbo-frame>
{% endtempl %}
<!-- src/hello/templates/hello.templ -->
{% package templates %}
{% templ HelloTemplate(message string) %}
<turbo-frame id="app-container">
<p>{%= message %}</p>
</turbo-frame>
{% endtempl %}
Run again #
Now if we run the app with docker-compose build
and docker-compose up
you should now be able to navigate between the apps, Turbo should render the frames correctly.
Congratulations, you've built your first MFEs web app!
Reference Code #
I've created a GitHub repo [3] for reference while following along.