💾 Archived View for capsule.adrianhesketh.com › 2021 › 10 › 23 › using-storybook-with-go-frontends captured on 2021-12-03 at 14:04:38. Gemini links have been rewritten to link to archived content

View Raw

More Information

⬅️ Previous capture (2021-11-30)

➡️ Next capture (2022-03-01)

-=-=-=-=-=-=-

capsule.adrianhesketh.com

home

Using Storybook with Go frontends

Storybook [0] is an open source tool for building UI components and pages in isolation.

[0]

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].

[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 %}

[2]

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].

[3]

Making it easier

There's a few steps to all of this.

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].

[4]

[5]

All of the Storybook example code is at [6]

[6]

The first thing is to export the Storybook configuration from the component library:

[7]

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.

[8]

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.

[9]

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].

[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]

[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.

More

Previous

Testing templ HTML rendering with goquery

Home

home