Server-Side Rendering

The server-side rendering template provides allows defining pages in React that are rendered on the server. Each page is packaged in an individual lambda function.

Dynamic routes are implemented using AWS API Gateway HTTP API and caching is supported using CloudFront.

Features

  • Server-side rendering of React pages
  • Styling using CSS modules supported
  • Styling using Tailwind supported
  • Hydration supported for client-side logic
  • Low latency, low cost and highly scalable by using the new AWS HTTP API and CloudFront
  • Minimal cold start times by bundling the source code of each page into separate Lambda functions
  • Fast packing and deployment using esbuild
  • Rapid configuration by dynamically defining routes through placing source files
  • All infrastructure defined in Terraform for easy customisation
  • Lightweight local testing with a custom Express.js server
  • TypeScript, ESLint and Prettier configured

Configure

The following key properties need to be configured for this template:

  • Lambda Name Prefix: The prefix to be used by Lambda names generated by this template for the defined routes.
  • Domain: The domain where the website should be deployed to. For instance, to publish the website to https://mydomain.com/ the domain mydomain.com needs to be configured.
  • Hosted Zone Domain: A Route 53 hosted zone that will allow adding the Domain as a record. For instance, in order to configure the domain mydomain.com, the hosted zone mydomain.com would be valid. For more details, please check Hosted Zone Configuration in the Goldstack documentation.

Getting Started

1. Project Setup

Before using this template, you need to configure the project. For this, please see the Getting Started Guide on the Goldstack documentation.

2. Setup Infrastructure

To stand up the infrastructure for this module, find the directory for this module in the packages/ folder and navigate to this folder in the command line. Then identify the name of the deployment you have defined in the Goldstack configuration tool. This can be found in the packages/[moduleName]/goldstack.json file. Look for the "deployments" property and there for the "name" of the first deployment. The name should either be dev or prod.

In order to stand up the infrastructure, run the following command:

yarn infra up [deploymentName]

This will be either yarn infra up dev or yarn infra up prod depending on your choice of deployment. Note that running this command can take a while.

Note that your website will not work yet. It first needs to be deployed as per instructions below.

3. Deploy Application

Once the infrastructure is successfully set up in AWS using yarn infra up, we can deploy the module. For this, simply run the following command:

yarn deploy [deploymentName]

This will either be yarn deploy dev or yarn deploy prod depending on your choice of deployment during project configuration.

You should now be able to access your website. The domain under which the website is deployed is configured in goldstack.json under "deployments[*].domain".

Development

The entry point for defining new pages and routes is in src/routes. The easiest way to get started is to modify or add new pages routes to the server by adding new folders and files. The template will automatically update the infrastructure configuration for the new routes defined, such as adding routes to the API Gateway or defining new Lambda functions.

There are a few things to keep in mind when defining new endpoints:

Defining Pages

When defining a new page in the src/routes folder by adding a new TypeScript, the following template needs to be followed:

import React, { useState } from 'react';
import { SSRHandler } from '@goldstack/template-ssr';

import { renderPage, hydrate } from './../render';
import styles from './$index.module.css';

const Index = (props: { message: string }): JSX.Element => {
  return (
    <>
      <div className="m-12">{props.message}</div>
    </>
  );
};

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const handler: ProxyHandler = async (event, context) => {
  return renderPage({
    component: Index,
    properties: {
      message: 'Hi there',
    },
    entryPoint: __filename,
    event: event,
  });
};

hydrate(Index);

export default Index;

Note the file should have the .tsx extension.

Defining API Endpoints

It is also possible to define API endpoints that do not include SSR.

See the following example that defines a file [id].ts.

Note the /* esbuild-ignore ui */ at the beginning of the file. We should add this to any file that should not be included in the client bundle.

/* esbuild-ignore ui */

import {
  Handler,
  APIGatewayProxyEventV2,
  APIGatewayProxyResultV2,
} from 'aws-lambda';

type ProxyHandler = Handler<APIGatewayProxyEventV2, APIGatewayProxyResultV2>;

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const handler: ProxyHandler = async (event, context) => {
  const id = event.pathParameters?.['id'] || 'not specified';

  return {
    statusCode: 201,
    body: JSON.stringify({
      message: `Accessing order [${id}]`,
    }),
  };
};

Note that endpoints that do not require SSR, can have the extension .ts.

Defining Routes

Routes are defined through the names of folders and source folders within the src/routes folder. There are a few rules to keep in mind:

  • Basic Routing: The names of files is used for the names of resources. For instance, src/routes/page.tsx will available under mypage.com/page.
  • Subfolders: Names of folders will be used in the path to resources. For instance, src/routes/group/page.tsx will be available under mypage.com/group/page.
  • Indices: For defining a route that matches / within a folder (or the root of the website), a source file with the name $index.tsx can be defined. For instance, src/routes/group/$index.tsx will be available under api.com/group/.
  • Default Fallback: To define a fallback that is called when no route is matched, define a source file with the name $default.tsx. There should only be one $default.tsx file in the API. This will match all paths that are not covered by other routes.
  • Path Parameters: Parameters in path are supported using the syntax {name}. For instance, src/user/{name}.tsx will make the parameter name available in the endpoint. Parameters are also supported as folder names.
  • Greedy Paths: If a parameter should match multiple resource levels, it can be defined as follows {greedy+}. For instance src/group/{greedy+}.tsx will match mypage.com/group/1 and mypage.com/group/some/path and all other paths under group/.

Static Resources

Files placed in the following folders will be served as static files for the website:

  • public/
  • static/

Files placed in the static/ folder will automatically be configured to be cached indefinitely by the CloudFront CDN.

Select resources from the public/ folder will also be served at the root of the website, such as favicon.ico.

For details, please check the CloudFront configuration in infra/aws/cloudfront.ts.

Updating Infrastructure

Note that after defining a new route, the infrastructure will need to be updated using yarn infra up [deployment name]. This is because new or changed routes will require changes to the API Gateway and/or the Lambda functions that are defined.

Writing Tests

The Goldstack template for this module contains an example of an integration test for the API. Test are easy to write and very fast to run by utilising a custom Express.js server. It is also very cheap to create instances of the API on AWS infrastructure; thus more sophisticated setups can run tests directly against the API deployed on AWS.

Here an example for a local test (ssr.spec.ts):

import getPort from 'find-free-port';
import fetch from 'node-fetch';

import { startTestServer, stopTestServer, getEndpoint } from './../module';

jest.setTimeout(120000);

describe('Should create page', () => {
  let port: undefined | number = undefined;

  beforeAll(async () => {
    port = await new Promise<number>((resolve, reject) => {
      getPort(
        process.env.TEST_SERVER_PORT || '50331',
        (err: any, p1: number) => {
          if (err) {
            reject(err);
            return;
          }
          resolve(p1);
        }
      );
    });
    await startTestServer(port);
  });

  test('Should receive response and support parameters', async () => {
    const res = await fetch(`${getEndpoint()}/`);
    const response = await res.text();
    expect(response).toContain('Hi there');
    // ensure CSS is compiled correctly and correct class names injected
    expect(response).toContain('-message-');
  });

  afterAll(async () => {
    await stopTestServer();
  });
});

It is also possible to test the React pages directly for asserting client-side behaviour. These tests need to follow a specific naming pattern: [testname].uispec.tsx or [testname].uispect.ts. The tests against the API instead should use the suffix [testname].spec.ts[x].

This is required since Jest needs to load different test environment for client-side and server-side tests.

See below an example for a client-side test ($index.uispec.tsx):

import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import Index from '../routes/$index';

describe('Render tests', () => {
  it('Should render component', () => {
    render(<Index message="run test"></Index>);

    expect(screen.getByText('run test', { exact: false })).toBeVisible();
  });
});

Best Practices

  • Keep Lambdas Lightweight: This template will package Lambdas only with the code that they require. Aim to minimise the number of dependencies that are imported into each handler function. The smaller the Lambda functions are, the less noticeable cold starts will be. Cold starts using Lambdas packaged by this module can be as low as 150 ms (with almost all of that time spent on AWS getting the basic infrastructure for the Lambda up and running).
  • Think RESTful: This module is not limited to server-side rendering. You can also defined backend APIs. For developing APIs it is advisable to develop APIs in a RESTful way. For a reference on best practices, see RESTful web API design by Microsoft.

Infrastructure

All infrastructure for this module is defined in Terraform. You can find the Terraform files for this template in the directory [moduleDir]/infra/aws. You can define multiple deployments for this template, for instance for development, staging and production environments.

If you configured AWS deployment before downloading your project, the deployments and their respective configurations are defined in [moduleDir]/goldstack.json.

The configuration tool will define one deployment. This will be either dev or prod depending on your choice during project configuration. In the example goldstack.json below, a deployment with the name dev is defined.

{
  "$schema": "./schemas/package.schema.json",
  "name": "...",
  "template": "...",
  "templateVersion": "...",
  "configuration": {},
  "deployments": [
    {
      "name": "dev",
      "awsRegion": "us-west-2",
      "awsUser": "awsUser",
      "configuration": {
        ...
      }
    }
  ]
}

Infrastructure Commands

Infrastructure commands for this template can be run using yarn. There are four commands in total:

  • yarn infra up: For standing up infrastructure.
  • yarn infra init: For initialising Terraform.
  • yarn infra plan: For running Terraform plan.
  • yarn infra apply: For running Terraform apply.
  • yarn infra destroy: For destroying all infrastructure using Terraform destroy.
  • yarn infra upgrade: For upgrading the Terraform versions (supported by the template). To upgrade to an arbitrary version, use yarn infra terraform.
  • yarn infra terraform: For running arbitrary Terraform commands.

For each command, the deployment they should be applied to must be specified.

yarn infra [command] [deploymentName]

For instance, to stand up the infrastructure for the dev deployment, the following command would need to be issued:

yarn infra up dev

Generally you will only need to run yarn infra up. However, if you are familiar with Terraform and want more fine-grained control over the deployment of your infrastructure, you can also use the other commands as required.

Note that for running yarn infra terraform, you will need to specify which command line arguments you want to provide to Terraform. By default, no extra arguments are provided:

yarn infra terraform [deployment] plan

If extra arguments are needed, such as variables, you can use the --inject-variables option, such as for running terraform plan:

yarn infra terraform [deployment] --inject-variables plan

If you want to interact with the remote backend, you can also provide the --inject-backend-config option, such as for running terraform init:

yarn infra terraform [deployment] --inject-backend-config init

Customizing Terraform

Goldstack templates make it very easy to customize infrastructure to your specific needs. The easiest way to do this is to simply edit the *.tf files in the infra/aws folder. You can make the changes you need and then run yarn infra up [deploymentName] to apply the changes.

The infra/aws folder contains a file variables.tf that contains the variables required for your deployment; for instance the domain name for a website. The values for these variables are defined in the module's goldstack.json file in the "configuration" property. There is one global configuration property that applies for all deployments and each deployment also has its own configuration property. In order to add a new variable, add the variable to variables.tf and then add it to the configuration for your template or to the configurations for the deployments.

Note that due to JavaScript and Terraform using different conventions for naming variables, Goldstack applies a basic transformation to variable names. Camel-case variables names are converted to valid variables names for Terraform by replacing every instance of a capital letter C with _c in the variable name. For instance:

myVariableName in the Goldstack configuration will translate to the Terraform variable my_variable_name as defined in variables.tf.

Terraform State

In order to manage your infrastructure, Terraform maintains a state for each deployment; to calculate required changes when the infrastructure is updated and also for destroying the infrastructure if it is no longer required. Goldstack by default will store the terraform state in the infra/aws folder as simple files.

This works well for deploying infrastructure from your local development environment but is not a good choice when building a CI/CD pipeline for the infrastructure definition. In that case, it is better to define Remote State. A popular choice many projects adopt here is to store the state in an S3 bucket. Please see the Terraform documentation for further details.

Deployment

This template can be packaged up and deployed to the deployments specified in goldstack.json. Note that deployment will only work after the infrastructure for the respective deployment has been stood up. To deploy your package, run the following script:

yarn deploy [deploymentName]

You can deploy each of the routes individually since they are all packaged up into individual lambdas. Provide a pattern that matches the filename/path of the route you want to deploy:

yarn deploy [deployment] [route pattern]

For instance, in order to deploy the $index.tsx route:

yarn deploy [deployment] index

Matching is performed using minimatch and a * is assumed at the start and end of the pattern provided.

Guides and How To

Adding environment variables

Environment variables are defined in the Terraform source code for this template. Specifically they are defined in the infra/aws/lambda_routes.tf file in the resource resource "aws_lambda_function" "this". Note that all lambdas share the same environment variables. By default, there are a few environment variables specified:

 environment {
    variables = {
      GOLDSTACK_DEPLOYMENT = var.name
      CORS                 = var.cors
      NODE_OPTIONS         = "--enable-source-maps"
    }
  }

Add your environment variables into the variables map:

 environment {
    variables = {
      GOLDSTACK_DEPLOYMENT = var.name
      CORS                 = var.cors
      NODE_OPTIONS         = "--enable-source-maps"
      YOUR_ENV_VAR = 'your env var value'
    }
  }

Usually environment variables should have different values depending on which environment the server is deployed to. This can be accomplished using Terraform variables. Change your variable declaration to the following:

YOUR_ENV_VAR = var.my_env

Then go into the file infra/aws/variables.tf and add the following definition:

variable "my_env" {
  description = "My environment variable"
  type = string
}

And finally add this variable to all deployment configurations in goldstack.json:

      "configuration": {
        "lambdaName": "my-lambda",
        "apiDomain": "api.mysite.com",
        "hostedZoneDomain": "mysite.com",
        "cors": "https://mysite.com",
        "myEnv": "Value for deployment"
      }

Note that the Terraform variable my_env translates to myEnv in the JSON definition (Just remove all _ and make the first character after _ uppercase for your variable definitions).

Lastly, to support local development make sure to define the variable correctly in all scripts in package.json. Specifically, you may want to define them for "test", and "watch".

    "test": "MY_ENV=localvalue jest --passWithNoTests --config=jest.config.js --detectOpenHandles",
    "watch": "PORT=8731 MY_ENV=localvalue nodemon --config nodemon.json --exec 'yarn node dist/src/local.js'"

Note that for credentials and other values that should not be committed to source code, it may be better to store these in AWS Secrets Manager and retrieve them using the AWS SDK based on the process.env.GOLDSTACK_DEPLOYMENT value provided.

It is also possible to provide the value of Terraform variables through environment variables during build time. For instance, if you have defined the variable my_env, simply provide the environment variable MY_ENV when calling yarn infra.

MY_ENV=value yarn infra up prod

This works very well in combination with secrets for GitHub actions.

- name: Update API infra
  run: |
    yarn workspace my-api infra up prod
  env:
    MY_ENV: ${{secrets.MY_ENV}}
    AWS_USER_NAME: goldstack-prod
    AWS_ACCESS_KEY_ID: ${{secrets.PROD_AWS_ACCESS_KEY_ID}}
    AWS_SECRET_ACCESS_KEY: ${{secrets.PROD_AWS_SECRET_ACCESS_KEY}}
    AWS_DEFAULT_REGION: us-west-2

Changing Esbuild behaviour

Github issue

Provide esbuild.config.json in the ./packages/serverless-api folder of your generated project with example config like this:

{
  "platform": "node"
}

This object will be used for build process on every serverless function

If you want to change esbuild config for specific function, just add esbuild config, like this:

src/routes/hello.ts
src/routes/hello.esbuild.config.json

Priority for resulting esbuild config is this (from highest to lowest):

Analysing Generated Bundles

It is often useful to analyse the bundles generated by esbuild to optimise their size. For this, the template will always provide esbuild metafiles in the ./distLambdas/zips folder. These can be analysed with a tool such as esbuild-visualizer.

Simply install this tool:

npm i -g esbuild-visualizer

And then analyse any of the metafiles generated:

esbuild-visualizer --metadata ./distLambda/zips/[your function name].meta.json

This will yield a stats.html file you can view with any web browser.

Bundle statistics

Troubleshooting and Frequently Asked Questions

DNS Name for API Cannot be resolved

After applying yarn infra up [deployment] and yarn deploy [deployment] it is not possible to call the API at https://[configuration.apiDomain]. An error such as Address cannot be resolved or DNSProbe failed is reported.

This is caused by changes to the deployed DNS hosted zone needing some time to propagate through the DNS network. Wait for 10-30 min and the API should be able to be called without problems. To validate your DNS name has been configured correctly, go to the AWS Route 53 Console, choose the region you have deployed, and validate there is a correct entry for the hosted zone you have selected. There should be an A entry such as the following:

[apiDomain].[hostedZone] A [id].cloudfront.net.

Concurrent Modification Error when Creating Infrastructure

The following error may be displayed sometime when running yarn infra up prod [deploymentName] for the first time. This is due to an error in the way Terraform schedules the creation of the resources. The easy solution to this problem is simply running yarn infra up prod [deploymentName] again.

Error: error creating API Gateway v2 route: ConflictException: Unable to complete operation due to concurrent modification.
Please try again later.

See Issue #40

Security Hardening

Configure HTTP Headers

This template uses CloudFront which can be used to configure security headers. Many recommended security headers are configured by the template by default, but some configurations have been purposefully left less strict, especially the Content Security Policy.

Please edit the resource "security_headers_policy" in the file ./infra/aws/cloudfront.tf to adjust the security header configurations according to the needs of your application.

AWS Service Accounts

This template requires further security hardening when deployed in critical production applications. Specifically the lambdas are given the role arn:aws:iam::aws:policy/AdministratorAccess" and this will grant the lambdas access to all resources on the AWS account, including the ability to create and destroy infrastructure. It is therefore recommended to grant the lambdas only rights to resources it needs access to, such as read and write permissions for an S3 bucket. This can be modified in infra/aws/lambda_shared.tf in the resource resource "aws_iam_role_policy_attachment" "lambda_admin_role_attach".

Note that in this templates all lambdas for the API share the same permissions. This is by design to simply setup and management of the infrastructure in the understanding that the API forms one integrated element of a system. If there are concerns about access to resourced being shared by multiple lambdas, another API can be created.

© 2024 Pureleap Pty. Ltd. and Contributors