Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add RemixSite construct #1800

Merged
merged 16 commits into from Jul 13, 2022
Merged

feat: Add RemixSite construct #1800

merged 16 commits into from Jul 13, 2022

Conversation

ctrlplusb
Copy link
Contributor

@ctrlplusb ctrlplusb commented Jun 10, 2022

RemixSite

This PR adds a RemixSite construct to support deployment of Remix applications. The construct supports multiple deployment models for the SSR Lambda; Lambda@Edge or APIGatewayV2 Lambda.

import type { StackContext } from "@serverless-stack/resources";
import { RemixSite, use } from "@serverless-stack/resources";
import { ApiStack } from "./api-stack";

export function WebStack(ctx: StackContext) {
  const api = use(ApiStack);

  const remixSite = new RemixSite(ctx.stack, "RemixSite", {
    path: "web",
    edge: true,
    environment: {
      API_URL: api.url,
    },
    customDomain: {
      domainName: 'remix-demo.sst.dev',
      hostedZone: 'sst.dev',
    }
  });

  ctx.stack.addOutputs({
    siteUrl: remixSite.url,
  });

  return remixSite;
}

What is Remix?

From their homepage;

Remix is a full stack web framework that lets you focus on the user interface and work back through web standards to deliver a fast, slick, and resilient user experience. People are gonna love using your stuff.

Motivation

I've only recently dug into Remix, and haven't used it in production, but I must say that my limited experience of it has already made it seem far more appealing to me than Next.js. There is far less to think about. It only has a single page rendering model - server side. This results in a much reduced cognitive overhead compared to Next.js. In addition to this they are leaning heavily into the standard APIs and practices of the Web platform. Overall I feel like they are pushing us into the right direction. I've additionally found it really easy to mentally model the difference between server and client code execution. Their .server.ts filename postfix strategy, whilst simple, is really effective at helping maintain the distinction.

From what I have read from their team they appear to favour edge based deployments. Whilst they already have an AWS Lambda compatible build - via their official Architect template. This deployment only supports an API Gateway based Lambda for SSR. In addition to this I am unsure if they are utilising CloudFront. There is a strong opportunity here for us to create an alternative AWS deployment that is more in line with their ideals.

Given this opportunity I believe this work could open the door to us having a conversation with the Remix team, and hopefully convince them to integrate an official Serverless Stack based template.

Whilst this initial implementation is meant to execute against "Vanilla" Remix application configurations that are created within an SST based project, I am designing the construct in a manner that we could support an official template solution within the Remix repository. I invite some discourse on this point as we evolve this implementation.

I believe that should we be integrated as an official template within Remix it would be a significant net gain for the Serverless Stack community, one that could hopefully drive increased adoption. There is definitely a hype train running against Remix, let's jump onboard!

Prerequisite Knowledge

Before being able to evaluate this PR it is important to have an understanding of some of the aspects of Remix which directly impacted the design of this construct.

"Vanilla" Remix Server

The remix.config.js exposes a configuration value called serverBuildTarget. When this value is not explicitly set it defaults to node-cjs. Executing the remix build command with serverBuildTarget being node-cjs will not result an executable Remix server being output. It instead outputs a "core server build" module which contains the following exports;

module.exports = {
  assets: () => assets_manifest_default,
  entry: () => entry,
  routes: () => routes,
};

The "core server build" enables you to write your own server implementation that would handle the server rendering of Remix in a manner you require.

For example you could write a middleware for Express that consumes this module. This does require that you understand the exposed data and API from the "core server build" module. The Remix team have provided packages to aid in this development. For example, the @remix-run/node package.

In terms of Express middleware, Remix already provides an implementation via their @remix-run/express package which is able to receive the "core server build" module and output an appropriate Express middleware.

You would then need to add a server.ts file to your Remix application, in which you bootstrap an Express server and attach the middleware. Below is a minimal example of this.

const path = require("path");
const express = require("express");

// 1️⃣  We are importing an official Remix package, which provides a factory
//    function to create an Express middleware which is able to perform server
//    rendering for the Remix application;
const { createRequestHandler } = require("@remix-run/express");

// 2️⃣  We need to import the "core server build" module that is output by
//    `remix build` (when using the "node-cjs" value for the "serverBuildTarget"
//     property within the remix.config.js file);
const remixCoreServerBUILD = require(path.join(process.cwd(), "build"));

const app = express();

// 3️⃣  Map the "public" folder so that our static assets along with the
//    "React client build" get served
app.use(express.static("public"));

app.all(
  "*",
  // 4️⃣  Create the Express middleware, passing in the core server build module
  createRequestHandler({
    build: remixCoreServerBUILD,
  })
);

const port = process.env.PORT || 3000;

app.listen(port, () => {
  console.log(`Express server listening on http://localhost:${port}`);
});

I very much recommend that you create a new vanilla Remix application with the following steps in order to build a deeper intuition about how what has been described above;

  1. Create a new Remix project, utilising the "Remix App Server" option;

    npx create-remix@latest

    When the template selection is displayed, select the "Remix App Server" option. This will result in the "core server build" being output when the remix build command is executed.

    Create vanilla remix project for express

    Note

    This template also includes a "@remix-run/serve" package. This package includes two things:

    1. The Remix dev server, started via remix dev, or the "dev" package.json script.

    2. A production grade Node server to act against the "core server build", serving your Remix application.

      We will be ignoring this for the purpose of our exercise as we want to build our own server implementation, and utilising this template is the quickest way to bootstrap a clean Remix project. You can safely remove the "start" script from your package.json.

  2. Run the build for the application;

    npm run build
  3. You will note that the following folders were output as a result of the build;

    • /build

      This folder contains the "core server build" as previously described.

      I'd encourage you to view the contents of the index.js file contained within this directory. At the bottom of the file you will see the "core server build" exports previously discussed.

    • /public/build

      This folder contains the "React client build", intended for use within the browser.

  4. Install the official Express package which enables you to create a Remix compatible middleware;

    npm install @remix-run/express
  5. Create a server.js file at the root of the project with the contents of the example shown previously within this section. 👆👆👆 (scroll up)

  6. Now we'll replace the Remix App Server with our Express server. Edit the package.json scripts to look like the following;

     "scripts": {
       "build": "remix build",
       "dev": "remix dev",
    -  "start": "remix-serve build"
    +  "start": "node server.js"
     },

    Note

    You would typically have to replace the dev command too when doing this level of customisation. Leaning into something like nodemon to help run your Express server in dev mode.

  7. Start your Express server;

    npm run start
  8. Open your browser to the following URL;

    http://localhost:3000

    You should see the Remix server response. 😎

Writing your own Remix server implementation clearly involves a bit of boilerplate, and understanding of the build output and APIs. Remix provides official templates to encapsulate this boilerplate for a variety of server models.

One of these templates is an "Express Server" template. You can select this template when bootstrapping a Remix application via their official create-remix CLI package, ensuring that the "Express Server" option is selected;

remix-bootstrap-express

The template contains a more robust implementation of the server.ts, along with a package.json scripts configuration which enables you to run your Express server both in development and production. I invite you to compare their "Express Server" template with the implementation created in the steps above.

The "Architect (AWS Lambda)" Template

Remix provides multiple templates to help you avoid writing boilerplate for your server deployment environment. These are selected when initializing your application via the create-remix CLI package;

remix-bootstrap-express

Each of these templates will bootstrap your Remix application with the required configuration, boilerplate files, package.json scripts, and NPM packages to support developing, building, and deploying Remix against the target environment.

Probably of greatest interest to us is the "Architect (AWS Lambda)" template. I'll review this template in more detail to give a more clear understanding of what was required to provide first class support within Remix.

Note

The Architect deployment currently only supports deployment of your Remix application against a traditional Lambda executed via API Gateway.

Bootstrapping a Remix application with the "Architect (AWS Lambda)" template produces the following file structure;

architect-files

As you can see it contains an app.arc file, which is specific to Architect. It other Architect specific implementation. It additionally has a server.js file at the root, which contains the following:

import { createRequestHandler } from "@remix-run/architect";
import * as build from "@remix-run/dev/server-build";

export const handler = createRequestHandler({
  build,
  mode: process.env.NODE_ENV,
});

Note how it imports the @remix-run/architect package. This is an official Remix package which enables you to create an API Gateway Lambda compatible handler against the "core server build".

The "core server build" isn't being imported directly, like in our Express server example. Instead it is being imported via the following line of code;

import * as build from "@remix-run/dev/server-build";

This acts as a token for the Remix compiler to know that it should replace the import with the contents of the "core server build". This additionally ensure TypeScript support is provided for the build, as they also include typing definitions within the @remix-run/dev package for this "dummy" module.

The file is consumed by the Remix compiler via the server configuration property within the remix.config.js file;

module.exports = {
  server: "./server.js",
  /* ... other config omitted */
};

When this configuration value is set the remix build command will bundle the contents of the file with the "core server build", performing the swap of the import statement with the actual "core server build" module.

The output of the build is an API Gateway Lambda handler which is able to perform the Remix server rendering logic of your Remix application.

Static Assets

Remix supports static assets via some convention. Anything put into the "public" folder should be served relative to the root of the domain when deployed.

The React client side code is bundled by default into the "public/build" folder (this is created when executing the remix build command).

Whilst this is considered the default behaviour it is possible to override the path at which the React client side code is output, as well as the public path (relative to the root of the domain) at which it should be considered hosted. The defaults for these are shown below;

module.exports = {
  // This is the output directory for the React client:
  assetsBuildDirectory: "public/build",
  // This is the URL path at which the React client should 
  // be considered hosted from:
  publicPath: "/build/",
  /* ... other config omitted */
};

Server Building

Building your Remix application is performed via their CLI;

remix build

Remix differs from other solutions, like Next.js, in that it doesn't expose the ability to override or extend the underlying tool they use to bundle your application.

The only customisation they offer around this is the ability to declare which dependencies should be bundled into your server build. By default the Remix build does not bundle any 3rd party dependencies into the server build.

In addition to this Remix has limited support for modern CSS solutions, and the team appears to favour an approach of manually adding a Tailwind based configuration to your application.

It is therefore highly likely that executing the remix build command in isolation might not produce all the output required for a deployment. Developers may have enhanced their "build" script within the package.json to additionally call the Tailwind compiler, for instance.

Solution Overview

This PR adds a RemixSite construct, enabling the deployment of "Vanilla" Remix applications (i.e. Remix applications that were bootstrapped with the "Remix App Server" option) to AWS.

It supports two deployment models for the SSR Lambda; via CloudFront@Edge or APIGatewayV2 Lambda. Both models utilise a CloudFront Distribution, where the SSR is performed against an APIGatewayV2 Lambda we configure a custom origin source on the CloudFront Distribution to direct traffic accordingly.

Toggling between the two models is performed via an edge prop on the construct;

new RemixSite(ctx.stack, "RemixSite", {
  path: "web",
  edge: true, // 👈
  environment: {
    API_URL: api.url,
  },
});

Note

Toggling the edge flag between deployments works seamlessly. This grants users a clear "upgrade" path should they wish to move from single region to global deployments.

The construct expects that you initialised a "Vanilla" Remix application via the "Remix, i.e. selecting the "Remix App Server" template;

remix-bootstrap-vanilla

Note

The above isn't a hard expectation. You could alternatively have manually bootstrapped a Remix application, adding the @remix-run/node and @remix-run/react, along with a default remix config, and the remix dev / remix build commands configured as scripts. It's easier to utilise the template though.

The solution does not require any additional configuration or boilerplate (except for doing the standard sst-env -- prefix on the "dev" script).

Similar to some of the other templates being exposed by Remix we will rely on conventions in regards to the Remix configuration. We expect that the following properties within the remix.config.js have their "default" values;

  • assetsBuildDirectory
  • publicPath
  • serverBuildPath
  • serverBuildTarget

This provides with a simpler Construct in that we can assume the same build output structure by the Remix application. To ensure these values are the expected, the construct will perform an assertion against them. We output a helpful error message in the case that we identify an invalid remix.config.js configuration.

Note

The other deployment types within Remix's official support list have the similar expectations. In fact, Remix has built in hard overrides within their Remix CLI to ensure that the expected values are being utilised for each deployment type.

To support a Lambda@Edge server, we have an internal server.js template file (see packages/resources/assets/RemixSite/server/server-template.js). The construct will inject the template into the Remix application build output, and then perform an ESBuild against it to create a fully bundled version of the server. This is performed during the synth process.

Users can continue to use the Remix dev server, via remix dev, to perform local development of their Remix applications, utilising the sst-env tool to inject their stack's environment variables. During deployment we utilise the same strategy that the Next.js site utilises to deploy the Lambda@Edge, whilst also performing the environment variable injection.

Note

There is a significant overlap between the NextjsSite and RemixSite constructs. We should raising some follow on work to refactor and clean some of the duplicated code into abstractions where we feel they would be valuable.

The construct also enables deployment of the Remix application's static assets. It provides thre separate CloudFront cache policies;

  • A cache policy for the React client side build. This is aggressively cached for a year as the Remix build guarantees that each file will be postfixed with a hash on the filename relating to the content of the file. In addition to this no CloudFront cache invalidation is required, as files will always be uniquely named.
  • A cache policy for additional "public" folder statics which have been added to the application. These have a much lower cache length policy as they could theoretically be updated with new content at any time, with the same filename. We include a CloudFront cache invalidation against these files too.
  • A cache policy for the SSR response. It's initial configuration is to not perform any caching, however, it affords an interesting opportunity for applications that are fairly static in nature to set a caching configuration for the server responses.

Overall the RemixSite construct is simpler than the NextjsSite construct. Remix doesn't provide as many features as Next.js - I believe a core principle of their design is to keep things "simple", recycling the same core aspect to support various use cases. This makes incorporating Remix within SST a more appealing aspect as we won't have to play as much "catch up" work compared to Next.js.

I have tested deployments of the construct via the use of Yalc. Thusfar it is working really well, although it suffers the same issue as the NextjsSite construct when trying to delete a Lambda@Edge that has already been distributed in the network. It would be great to have a strategy to deal with this case.

I have additionally added tests, although I feel these should be expanded to cover more cases, and I have updated the www documentation site;

image

Todo

  • Fix environment variable substitution.
  • Add tests for construct.
  • Add www documentation for construct.
  • Incorporate recent changes that have been performed against NextjsSite
  • Expand tests to account for edge flag
  • Update docs and PR description per new edge toggle feature
  • Final parse on docs (@fwang)

@ctrlplusb ctrlplusb force-pushed the feature-remix-site branch 5 times, most recently from ea431b6 to 30626d7 Compare June 14, 2022 16:27
@fwang
Copy link
Contributor

fwang commented Jun 15, 2022

Keeping track a list of threads looking for the Remix construct:

@ctrlplusb ctrlplusb force-pushed the feature-remix-site branch 2 times, most recently from f20a12c to cbcd939 Compare June 16, 2022 07:45
@ctrlplusb ctrlplusb changed the title Add RemixSite construct feat: Add RemixSite construct Jun 16, 2022
@ctrlplusb ctrlplusb force-pushed the feature-remix-site branch 5 times, most recently from 9c0f95c to f52036c Compare June 17, 2022 14:52
@ACPixel
Copy link

ACPixel commented Jun 17, 2022

Just want to say I appreciate the insane amount of work you are putting into this PR.

@adsc
Copy link

adsc commented Jun 18, 2022

This is amazing. Really looking forward to using it in SST. I currently use a non-SST CDK based stack for my Remix stuff.

@ctrlplusb ctrlplusb marked this pull request as ready for review June 19, 2022 02:08
@ctrlplusb ctrlplusb force-pushed the feature-remix-site branch 2 times, most recently from f5c0990 to b892667 Compare June 20, 2022 05:51
@ctrlplusb ctrlplusb force-pushed the feature-remix-site branch 8 times, most recently from a7015bd to d029a70 Compare July 4, 2022 15:20
@ctrlplusb
Copy link
Contributor Author

ctrlplusb commented Jul 6, 2022

I had a look at the network tab, and it doesn't appear that caching is working as expected. I am getting misses on the CF cache, and the Cache-Control header is not being returned for the browser cache;

Screenshot 2022-07-06 at 21 21 05


Update: spoke too soon. The "build" folder is being cached in CF, although the "Cache-Control" header isn't being returned in the response. So it appears only the favicon.ico isn't being cached in CF. I'll try add another file to the public folder and see if it this holds true to all public statics. This may just be a special case for favicon.ico.


Update2: Yeah, just some general inconsistencies with the caching policies. Doing some experiments.


Updated3: Solved by the below commits and verified in my network panel ✅

@fwang
Copy link
Contributor

fwang commented Jul 8, 2022

@ctrlplusb I removed the function alias, let me know if that causes any issue for you.

I still have some work around the docs for tmr. Apart from that, I think the code is ready!

@ctrlplusb
Copy link
Contributor Author

confetti-cat

@ctrlplusb
Copy link
Contributor Author

Rebased. FYI.

@fezproof
Copy link

Very excited to see this get in. Is there anything I can do to help this get finished up?

@fwang
Copy link
Contributor

fwang commented Jul 13, 2022

Very excited to see this get in. Is there anything I can do to help this get finished up?

Will be launching tmr or the day after :)

@fwang fwang merged commit 30ca1ca into sst:master Jul 13, 2022
@fwang fwang added the enhancement New feature or request label Jul 13, 2022
@github-actions github-actions bot mentioned this pull request Jul 13, 2022
@fwang
Copy link
Contributor

fwang commented Jul 13, 2022

Released in 1.4.0 Hooray!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants