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) %}
	<header data-testid="headerTemplate">
		<h1>{%= name %}</h1>
	</header>
{% 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.