💾 Archived View for capsule.adrianhesketh.com › 2021 › 10 › 23 › using-storybook-with-go-frontends captured on 2021-11-30 at 20:18:30. Gemini links have been rewritten to link to archived content
-=-=-=-=-=-=-
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].
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`.
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].
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].
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) } }
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.
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) } }
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]
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.
Testing templ HTML rendering with goquery