capsule.adrianhesketh.com
Using Storybook with Go frontends
Storybook [0] is an open source tool for building UI components and pages in isolation.
I've used it with lots of React projects, where it's been a great way to build out layouts and to allow developers to share, document, and preview components in isolation.
However, it's not just for React. Storybook also supports server-side rendered components [1].
Configuring storybook
Once Storybook Server has been installed using `npx`, it must be configured to point at a HTTP endpoint that returns HTML.
To connect Storybook Server to a local Go server that's listening on port 60606, we'd place that URL into the `.storybook/preview.js` file.
export const parameters = {
server: {
url: "http://localhost:60606/storybook_preview"
}
};
Storybook must also be configured to list the components that it can find, and any parameters that will be passed to the server-side rendered component.
For example, with a simple header `templ` [2] component, we'd need to tell Storybook server that the `name` parameter can be configured.
{% templ headerTemplate(name string) %}
{%= name %}
{% endtempl %}
To do this, we have to put a `{componentName}.stories.json` file in the `stories` directory, e.g. `headerTemplate`.
The `title` field contains the name of the component.
The `parameters/server/id` field contains the HTTP path of where the `headerTemplate` will be rendered by the backend. This is added to the `url` defined in the `.storybook/preview.js` file, so for this configuration, Storybook will send a HTTP request to `http://localhost:60606/storybook_preview/headerTemplate
The `args` section contains a map of the template's parameter names to default values. The example `headerTemplate` accepts a `name` parameter that is rendered within the `<h1>` element, so the map contains `"name": "Page Name"`.
The `argTypes` section defines the type of input that Storybook will use to render to allow users to edit the value of the parameters in the preview.
Finally, the `stories` section contains pre-configured variants of the template. I've just left a `Default` story which uses the default `args` to render the component.
{
"title": "headerTemplate",
"parameters": {
"server": {
"id": "headerTemplate"
}
},
"args": {
"name": "Page Name"
},
"argTypes": {
"name": {
"control": "text"
}
},
"stories": [
{
"name": "Default",
"Args": {}
}
]
}
Storybook Server can then be started by running `npm run storybook` which starts a Node.js server. However, without a Go server running to render the component, there's nothing to see.
Storybook Server can also be built into a static website which includes the config using the `npm run build-storybook` command. This outputs to a directory called `storybook-static`.
Go server
For each component, Storybook Server sends a HTTP request to the server configured in the `preview.js` file.
Any `args` configured in each `*.stories.json` file are passed as querystring parameters, so the `headerTemplate` in the example above sends a request to `http://localhost:60606/storybook_preview/headerTemplate?name=Page+Name`
The Go server then needs to respond to this request with HTML.
It's then easy to setup a web server, and a custom HTTP handler to do that for each component.
func main() {
http.HandleFunc("/storybook_preview/headerTemplate", headerTemplateHandler)
http.ListenAndServe(":60606", nil)
}
func headerTemplateHandler(w http.ResponseWriter, r *http.Request) {
// Read the name from the querystring.
name := r.URL.Query().Get("name")
// Render the component.
templ.Handler(headerTemplate(name)).ServeHTTP(w, r)
}
With Storybook Server, and the Go server running at the same time, you can get dynamic previews [3].
Making it easier
There's a few steps to all of this.
- Install Storybook Server.
- Create `*.stories.json` file for each component.
- Run the Storybook Server.
- Create a Go server.
- Create a HTTP handler for each component.
- Run the Go server.
When you add a new component, or change its parameters, you've also got to remember to rebuild and restart the Storybook Server.
I wanted to make this really easy in `templ`, so I created a `storybook` package that downloads and installs Storybook if required, configures the stories, builds the static storybook when required, and starts a local Go server that handles the rendering, and hosting of the Storybook.
This can be coupled with hot reloading tools like air [4] to get it to rebuild [5].
All of the Storybook example code is at [6]
The first thing is to export the Storybook configuration from the component library:
package example
import (
"github.com/a-h/templ/storybook"
)
func Storybook() *storybook.Storybook {
s := storybook.New()
s.AddComponent("headerTemplate", headerTemplate,
storybook.TextArg("name", "Page Name"))
s.AddComponent("footerTemplate", footerTemplate)
return s
}
Then, it's possible to make an executable for local execution that imports it [8]. Running this program will download Storybook, configure it, and run it.
package main
import (
"context"
"fmt"
"os"
"github.com/a-h/templ/storybook/example"
)
func main() {
s := example.Storybook()
if err := s.ListenAndServeWithContext(context.Background()); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
}
Hosting it in AWS
Creating a Storybook is great, but it's most useful when you can share it with others, so I put together a way to host it in AWS.
The new App Runner service is a good choice for lightweight applications like this, and it's easy to bundle everything up, but it costs a minimum of $5 a month, so I spent a bit of time to rework it to run in Lambda so that people wouldn't be put off by the cost.
The first thing was to create a Lambda function to run the code.
Creating a Lambda function
The local executable does lots of work when `ListenAndServe` is called. Including downloading Storybook and configuring it. This isn't good inside a Lambda function, because it will happen every time the Lambda container is resarted (a cold start), so the process is split into a build step that downloads and configures Storybook, and a run Lambda function that collects the build output and runs it.
First, the program imports the `example` component library, and calls the `Storybook` function to get all of the configuration.
The `build` function does all of the downloading and configuration of Storybook. This has to be executed before deployment.
var s = example.Storybook()
func build() {
if err := s.Build(context.Background()); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
Go has a brilliant feature where you can embed entire directories, or individual files, into variables by using a special `go:embed` comment.
This makes it really easy to replace serving files from disk on the local web server with serving files straight out of RAM.
// Embed the build output into the Lambda.
// The build output is only 4MB, so there's plenty of space.
//go:embed storybook-server/storybook-static
var storybookStatic embed.FS
func run() {
// Replace the filesystem handler with the embedded data.
rooted, _ := fs.Sub(storybookStatic, "storybook-server/storybook-static")
s.StaticHandler = http.FileServer(http.FS(rooted))
// Start a Lambda handler.
lambda.Start(handler)
}
The Storybook handler is a standard Go HTTP handler, so I wrote a function to map from an `APIGatewayV2HTTPRequest` to a HTTP request, and from a HTTP response back to an `APIGatewayV2HTTPResponse`.
func handler(ctx context.Context, e events.APIGatewayV2HTTPRequest) (resp events.APIGatewayV2HTTPResponse, err error) {
// Record the result.
w := httptest.NewRecorder()
u := e.RawPath
if len(e.RawQueryString) > 0 {
u += "?" + e.RawQueryString
}
r := httptest.NewRequest(e.RequestContext.HTTP.Method, u, nil)
s.ServeHTTP(w, r)
// Convert it to an API Gateway response.
result := w.Result()
resp.StatusCode = result.StatusCode
bdy, err := ioutil.ReadAll(w.Result().Body)
if err != nil {
return
}
resp.Body = string(bdy)
if len(result.Header) > 0 {
resp.Headers = make(map[string]string, len(result.Header))
for k := range result.Header {
v := result.Header.Get(k)
resp.Headers[k] = v
}
}
cookies := result.Cookies()
if len(cookies) > 0 {
resp.Cookies = make([]string, len(cookies))
for i := 0; i < len(cookies); i++ {
resp.Cookies[i] = cookies[i].String()
}
}
return
}
All that was left was to make it possible to run either the `build` or the `run` (default) operation.
func main() {
if len(os.Args) < 2 {
run()
}
switch os.Args[1] {
case "build":
build()
case "run":
run()
default:
fmt.Printf("unexpected command %q\n", os.Args[1])
os.Exit(1)
}
}
CDK deployment
With a Lambda function handler, I could create a HTTP endpoint to serve up the Storybook using CDK [10].
The CDK takes care of building the Go function.
bundlingOptions := &awslambdago.BundlingOptions{
GoBuildFlags: &[]*string{jsii.String(`-ldflags "-s -w"`)},
}
f := awslambdago.NewGoFunction(stack, jsii.String("storybookHandler"), &awslambdago.GoFunctionProps{
Runtime: awslambda.Runtime_GO_1_X(),
Entry: jsii.String("../lambda"),
Bundling: bundlingOptions,
MemorySize: jsii.Number(1024),
Timeout: awscdk.Duration_Millis(jsii.Number(15000)),
})
And then, adding a HTTP API Gateway to call the Lambda function is just a few lines of code.
fi := awsapigatewayv2integrations.NewLambdaProxyIntegration(&awsapigatewayv2integrations.LambdaProxyIntegrationProps{
Handler: f,
PayloadFormatVersion: awsapigatewayv2.PayloadFormatVersion_VERSION_2_0(),
})
endpoint := awsapigatewayv2.NewHttpApi(stack, jsii.String("storybookHttpApi"), &awsapigatewayv2.HttpApiProps{
DefaultIntegration: fi,
})
This pops out a HTTPS link on the Internet. I like to output the URL at the end so I can see where to visit.
awscdk.NewCfnOutput(stack, jsii.String("storybookEndpointUrl"), &awscdk.CfnOutputProps{
ExportName: jsii.String("storybookEndpointUrl"),
Value: endpoint.Url(),
})
I've hosted it up at [11]
Summary
It's possible to have a Storybook of server-side rendered Go UI components that provides a way for other developers to interact with components.
CDK can be used to deploy the Storybook to AWS using Lambda functions and API Gateway.