├── jest.setup.ts ├── remix.env.d.ts ├── app ├── images │ └── favicon.png ├── routes │ ├── demos │ │ ├── correct.tsx │ │ ├── about │ │ │ ├── index.tsx │ │ │ └── whoa.tsx │ │ ├── params.tsx │ │ ├── about.tsx │ │ ├── params │ │ │ ├── index.tsx │ │ │ └── $id.tsx │ │ └── actions.tsx │ └── index.tsx ├── entry.client.tsx ├── styles │ ├── dark.css │ ├── demos │ │ ├── about.css │ │ └── remix.css │ └── global.css ├── entry.server.tsx ├── utils.server.tsx └── root.tsx ├── .gitignore ├── server ├── index.ts └── createRequestHandler.ts ├── jest.config.js ├── remix.config.js ├── cdk.json ├── tsconfig.json ├── bin └── cdk-remix-app.ts ├── package.json ├── README.md └── lib └── cdk-remix-app-stack.ts /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import { installGlobals } from "@remix-run/node"; 2 | 3 | installGlobals(); 4 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /app/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajhaining/remix-cloudfront-cdk-example/HEAD/app/images/favicon.png -------------------------------------------------------------------------------- /app/routes/demos/correct.tsx: -------------------------------------------------------------------------------- 1 | export default function NiceWork() { 2 | return

You got it right!

; 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | /server/build 7 | 8 | .cdk.staging 9 | cdk.out 10 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from "react-dom"; 2 | import { RemixBrowser } from "remix"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import { createRequestHandler } from "./createRequestHandler"; 2 | 3 | export const handler = createRequestHandler({ 4 | build: require("./build"), 5 | }); 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | setupFiles: ["./jest.setup.ts"], 6 | }; 7 | -------------------------------------------------------------------------------- /app/styles/dark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-foreground: hsl(0, 0%, 100%); 3 | --color-background: hsl(0, 0%, 7%); 4 | --color-links: hsl(213, 100%, 73%); 5 | --color-links-hover: hsl(213, 100%, 80%); 6 | --color-border: hsl(0, 0%, 25%); 7 | } 8 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev/config').AppConfig} 3 | */ 4 | module.exports = { 5 | appDirectory: "app", 6 | browserBuildDirectory: "public/build", 7 | publicPath: "/build/", 8 | serverBuildDirectory: "server/build", 9 | devServerPort: 8002, 10 | }; 11 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/cdk-remix-app.ts", 3 | "context": { 4 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 5 | "@aws-cdk/core:stackRelativeExports": true, 6 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 7 | "@aws-cdk/aws-lambda:recognizeVersionProps": true, 8 | "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/routes/demos/about/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "remix"; 2 | 3 | export default function AboutIndex() { 4 | return ( 5 |
6 |

7 | You are looking at the index route for the /about URL 8 | segment, but there are nested routes as well! 9 |

10 |

11 | 12 | Check out one of them here. 13 | 14 |

15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/routes/demos/about/whoa.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "remix"; 2 | 3 | export default function AboutIndex() { 4 | return ( 5 |
6 |

7 | Whoa, this is a nested route! We render the /about layout 8 | route component, and its Outlet renders our route 9 | component. 🤯 10 |

11 |

12 | 13 | 14 | Go back to the /about index. 15 | 16 | 17 |

18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "server/index.ts"], 3 | "exclude": ["node_modules", "cdk.out"], 4 | "compilerOptions": { 5 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ES2019", 11 | "strict": true, 12 | "paths": { 13 | "~/*": ["./app/*"] 14 | }, 15 | 16 | // Remix takes care of building everything in `remix build`. 17 | "noEmit": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { renderToString } from "react-dom/server"; 2 | import { RemixServer } from "remix"; 3 | import type { EntryContext } from "remix"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | let markup = renderToString( 12 | 13 | ); 14 | 15 | responseHeaders.set("Content-Type", "text/html"); 16 | 17 | return new Response("" + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /app/utils.server.tsx: -------------------------------------------------------------------------------- 1 | // Sometimes some modules don't work in the browser, Remix will generally be 2 | // able to remove server-only code automatically as long as you don't import it 3 | // directly from a route module (that's where the automatic removal happens). If 4 | // you're ever still having trouble, you can skip the remix remove-server-code 5 | // magic and drop your code into a file that ends with `.server` like this one. 6 | // Remix won't even try to figure things out on its own, it'll just completely 7 | // ignore it for the browser bundles. On a related note, crypto can't be 8 | // imported directly into a route module, but if it's in this file you're fine. 9 | import { createHash } from "crypto"; 10 | 11 | export function hash(str: string) { 12 | return createHash("sha1").update(str).digest("hex").toString(); 13 | } 14 | -------------------------------------------------------------------------------- /app/styles/demos/about.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Whoa whoa whoa, wait a sec...why are we overriding global CSS selectors? 3 | * Isn't that kind of scary? How do we know this won't have side effects? 4 | * 5 | * In Remix, CSS that is included in a route file will *only* show up on that 6 | * route (and for nested routes, its children). When the user navigates away 7 | * from that route the CSS files linked from those routes will be automatically 8 | * unloaded, making your styles much easier to predict and control. 9 | * 10 | * Read more about styling routes in the docs: 11 | * https://remix.run/guides/styling 12 | */ 13 | 14 | :root { 15 | --color-foreground: hsl(0, 0%, 7%); 16 | --color-background: hsl(56, 100%, 50%); 17 | --color-links: hsl(345, 56%, 39%); 18 | --color-links-hover: hsl(345, 51%, 49%); 19 | --color-border: rgb(184, 173, 20); 20 | --font-body: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 21 | Liberation Mono, Courier New, monospace; 22 | } 23 | 24 | .about__intro { 25 | max-width: 500px; 26 | } 27 | -------------------------------------------------------------------------------- /bin/cdk-remix-app.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import "source-map-support/register"; 3 | import * as cdk from "aws-cdk-lib"; 4 | import { CdkRemixAppStack } from "../lib/cdk-remix-app-stack"; 5 | 6 | const app = new cdk.App(); 7 | new CdkRemixAppStack(app, "CdkRemixAppStack", { 8 | /* If you don't specify 'env', this stack will be environment-agnostic. 9 | * Account/Region-dependent features and context lookups will not work, 10 | * but a single synthesized template can be deployed anywhere. */ 11 | 12 | /* Uncomment the next line to specialize this stack for the AWS Account 13 | * and Region that are implied by the current CLI configuration. */ 14 | // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, 15 | 16 | /* Uncomment the next line if you know exactly what Account and Region you 17 | * want to deploy the stack to. */ 18 | env: { region: "us-east-1" }, 19 | 20 | /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */ 21 | }); 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "remix-app-template", 4 | "description": "", 5 | "license": "", 6 | "scripts": { 7 | "build": "remix build", 8 | "dev": "remix dev", 9 | "postinstall": "remix setup node", 10 | "start": "remix-serve build" 11 | }, 12 | "dependencies": { 13 | "@remix-run/node": "^1.0.6", 14 | "@remix-run/react": "^1.0.6", 15 | "@remix-run/serve": "^1.0.6", 16 | "react": "^17.0.2", 17 | "react-dom": "^17.0.2", 18 | "remix": "^1.0.6" 19 | }, 20 | "devDependencies": { 21 | "@remix-run/dev": "^1.0.6", 22 | "@types/aws-lambda": "^8.10.85", 23 | "@types/jest": "^27.0.3", 24 | "@types/react-dom": "^17.0.11", 25 | "@types/react": "^17.0.37", 26 | "aws-cdk-lib": "2.0.0", 27 | "aws-cdk": "2.0.0", 28 | "constructs": "^10.0.0", 29 | "jest": "^27.4.3", 30 | "ts-jest": "^27.0.7", 31 | "ts-node": "^10.4.0", 32 | "typescript": "^4.5.2" 33 | }, 34 | "engines": { 35 | "node": ">=14" 36 | }, 37 | "sideEffects": false 38 | } 39 | -------------------------------------------------------------------------------- /app/routes/demos/params.tsx: -------------------------------------------------------------------------------- 1 | import { useCatch, Link, json, useLoaderData, Outlet } from "remix"; 2 | 3 | export function meta() { 4 | return { title: "Boundaries Demo" }; 5 | } 6 | 7 | export default function Boundaries() { 8 | return ( 9 |
10 |
11 | 12 |
13 | 14 | 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix! 2 | 3 | - [Remix Docs](https://remix.run/docs) 4 | 5 | ## Development 6 | 7 | From your terminal: 8 | 9 | ```sh 10 | npm run dev 11 | ``` 12 | 13 | This starts your app in development mode, rebuilding assets on file changes. 14 | 15 | ## Deployment 16 | 17 | First, build your app for production: 18 | 19 | ```sh 20 | npm run build 21 | ``` 22 | 23 | Then run the app in production mode: 24 | 25 | ```sh 26 | npm start 27 | ``` 28 | 29 | Now you'll need to pick a host to deploy it to. 30 | 31 | ### DIY 32 | 33 | If you're familiar with deploying node applications, the built-in Remix app server is production-ready. 34 | 35 | Make sure to deploy the output of `remix build` 36 | 37 | - `build/` 38 | - `public/build/` 39 | 40 | ### Using a Template 41 | 42 | When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server. 43 | 44 | ```sh 45 | cd .. 46 | # create a new project, and pick a pre-configured host 47 | npx create-remix@latest 48 | cd my-new-remix-app 49 | # remove the new project's app (not the old one!) 50 | rm -rf app 51 | # copy your app over 52 | cp -R ../my-old-remix-app/app app 53 | ``` 54 | -------------------------------------------------------------------------------- /app/routes/demos/about.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "remix"; 2 | import type { MetaFunction, LinksFunction } from "remix"; 3 | 4 | import stylesUrl from "~/styles/demos/about.css"; 5 | 6 | export let meta: MetaFunction = () => { 7 | return { 8 | title: "About Remix", 9 | }; 10 | }; 11 | 12 | export let links: LinksFunction = () => { 13 | return [{ rel: "stylesheet", href: stylesUrl }]; 14 | }; 15 | 16 | export default function Index() { 17 | return ( 18 |
19 |
20 |

About Us

21 |

22 | Ok, so this page isn't really about us, but we did want to 23 | show you a few more things Remix can do. 24 |

25 |

26 | Did you notice that things look a little different on this page? The 27 | CSS that we import in the route file and include in its{" "} 28 | links export is only included on this route and its 29 | children. 30 |

31 |

32 | Wait a sec...its children? To understand what we mean by 33 | this,{" "} 34 | 35 | read all about nested routes in the docs 36 | 37 | . 38 |

39 |
40 | 41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /app/routes/demos/params/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCatch, Link, json, useLoaderData, Outlet } from "remix"; 2 | import type { LoaderFunction } from "remix"; 3 | 4 | export default function Boundaries() { 5 | return ( 6 | <> 7 |

Params

8 |

9 | When you name a route segment with $ like{" "} 10 | routes/users/$userId.js, the $ segment will be parsed from 11 | the URL and sent to your loaders and actions by the same name. 12 |

13 |

Errors

14 |

15 | When a route throws and error in it's action, loader, or component, 16 | Remix automatically catches it, won't even try to render the component, 17 | but it will render the route's ErrorBoundary instead. If the route 18 | doesn't have one, it will bubble up to the routes above it until it hits 19 | the root. 20 |

21 |

So be as granular as you want with your error handling.

22 |

Not Found

23 |

24 | (and other{" "} 25 | 26 | client errors 27 | 28 | ) 29 |

30 |

31 | Loaders and Actions can throw a Response instead of an 32 | error and Remix will render the CatchBoundary instead of the component. 33 | This is great when loading data from a database isn't found. As soon as 34 | you know you can't render the component normally, throw a 404 response 35 | and send your app into the catch boundary. Just like error boundaries, 36 | catch boundaries bubble, too. 37 |

38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/styles/global.css: -------------------------------------------------------------------------------- 1 | /* 2 | * You can just delete everything here or keep whatever you like, it's just a 3 | * quick baseline! 4 | */ 5 | :root { 6 | --color-foreground: hsl(0, 0%, 7%); 7 | --color-background: hsl(0, 0%, 100%); 8 | --color-links: hsl(213, 100%, 52%); 9 | --color-links-hover: hsl(213, 100%, 43%); 10 | --color-border: hsl(0, 0%, 82%); 11 | --font-body: -apple-system, "Segoe UI", Helvetica Neue, Helvetica, Roboto, 12 | Arial, sans-serif, system-ui, "Apple Color Emoji", "Segoe UI Emoji"; 13 | } 14 | 15 | html { 16 | box-sizing: border-box; 17 | } 18 | 19 | *, 20 | *::before, 21 | *::after { 22 | box-sizing: inherit; 23 | } 24 | 25 | :-moz-focusring { 26 | outline: auto; 27 | } 28 | 29 | :focus { 30 | outline: var(--color-links) solid 2px; 31 | outline-offset: 2px; 32 | } 33 | 34 | html, 35 | body { 36 | padding: 0; 37 | margin: 0; 38 | background-color: var(--color-background); 39 | color: var(--color-foreground); 40 | } 41 | 42 | body { 43 | font-family: var(--font-body); 44 | line-height: 1.5; 45 | } 46 | 47 | a { 48 | color: var(--color-links); 49 | text-decoration: none; 50 | } 51 | 52 | a:hover { 53 | color: var(--color-links-hover); 54 | text-decoration: underline; 55 | } 56 | 57 | hr { 58 | display: block; 59 | height: 1px; 60 | border: 0; 61 | background-color: var(--color-border); 62 | margin-top: 2rem; 63 | margin-bottom: 2rem; 64 | } 65 | 66 | input:where([type="text"]), 67 | input:where([type="search"]) { 68 | display: block; 69 | border: 1px solid var(--color-border); 70 | width: 100%; 71 | font: inherit; 72 | line-height: 1; 73 | height: calc(1ch + 1.5em); 74 | padding-right: 0.5em; 75 | padding-left: 0.5em; 76 | background-color: hsl(0 0% 100% / 20%); 77 | color: var(--color-foreground); 78 | } 79 | 80 | .sr-only { 81 | position: absolute; 82 | width: 1px; 83 | height: 1px; 84 | padding: 0; 85 | margin: -1px; 86 | overflow: hidden; 87 | clip: rect(0, 0, 0, 0); 88 | white-space: nowrap; 89 | border-width: 0; 90 | } 91 | 92 | .container { 93 | --gutter: 16px; 94 | width: 1024px; 95 | max-width: calc(100% - var(--gutter) * 2); 96 | margin-right: auto; 97 | margin-left: auto; 98 | } 99 | -------------------------------------------------------------------------------- /app/styles/demos/remix.css: -------------------------------------------------------------------------------- 1 | /* 2 | * You probably want to just delete this file; it's just for the demo pages. 3 | */ 4 | .remix-app { 5 | display: flex; 6 | flex-direction: column; 7 | min-height: 100vh; 8 | min-height: calc(100vh - env(safe-area-inset-bottom)); 9 | } 10 | 11 | .remix-app > * { 12 | width: 100%; 13 | } 14 | 15 | .remix-app__header { 16 | padding-top: 1rem; 17 | padding-bottom: 1rem; 18 | border-bottom: 1px solid var(--color-border); 19 | } 20 | 21 | .remix-app__header-content { 22 | display: flex; 23 | justify-content: space-between; 24 | align-items: center; 25 | } 26 | 27 | .remix-app__header-home-link { 28 | width: 106px; 29 | height: 30px; 30 | color: var(--color-foreground); 31 | } 32 | 33 | .remix-app__header-nav ul { 34 | list-style: none; 35 | margin: 0; 36 | display: flex; 37 | align-items: center; 38 | gap: 1.5em; 39 | } 40 | 41 | .remix-app__header-nav li { 42 | font-weight: bold; 43 | } 44 | 45 | .remix-app__main { 46 | flex: 1 1 100%; 47 | } 48 | 49 | .remix-app__footer { 50 | padding-top: 1rem; 51 | padding-bottom: 1rem; 52 | border-top: 1px solid var(--color-border); 53 | } 54 | 55 | .remix-app__footer-content { 56 | display: flex; 57 | justify-content: center; 58 | align-items: center; 59 | } 60 | 61 | .remix__page { 62 | --gap: 1rem; 63 | --space: 2rem; 64 | display: grid; 65 | grid-auto-rows: min-content; 66 | gap: var(--gap); 67 | padding-top: var(--space); 68 | padding-bottom: var(--space); 69 | } 70 | 71 | @media print, screen and (min-width: 640px) { 72 | .remix__page { 73 | --gap: 2rem; 74 | grid-auto-rows: unset; 75 | grid-template-columns: repeat(2, 1fr); 76 | } 77 | } 78 | 79 | @media screen and (min-width: 1024px) { 80 | .remix__page { 81 | --gap: 4rem; 82 | } 83 | } 84 | 85 | .remix__page > main > :first-child { 86 | margin-top: 0; 87 | } 88 | 89 | .remix__page > main > :last-child { 90 | margin-bottom: 0; 91 | } 92 | 93 | .remix__page > aside { 94 | margin: 0; 95 | padding: 1.5ch 2ch; 96 | border: solid 1px var(--color-border); 97 | border-radius: 0.5rem; 98 | } 99 | 100 | .remix__page > aside > :first-child { 101 | margin-top: 0; 102 | } 103 | 104 | .remix__page > aside > :last-child { 105 | margin-bottom: 0; 106 | } 107 | 108 | .remix__form { 109 | display: flex; 110 | flex-direction: column; 111 | gap: 1rem; 112 | padding: 1rem; 113 | border: 1px solid var(--color-border); 114 | border-radius: 0.5rem; 115 | } 116 | 117 | .remix__form > * { 118 | margin-top: 0; 119 | margin-bottom: 0; 120 | } 121 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction, LoaderFunction } from "remix"; 2 | import { useLoaderData, json, Link } from "remix"; 3 | 4 | type IndexData = { 5 | resources: Array<{ name: string; url: string }>; 6 | demos: Array<{ name: string; to: string }>; 7 | }; 8 | 9 | // Loaders provide data to components and are only ever called on the server, so 10 | // you can connect to a database or run any server side code you want right next 11 | // to the component that renders it. 12 | // https://remix.run/api/conventions#loader 13 | export let loader: LoaderFunction = () => { 14 | let data: IndexData = { 15 | resources: [ 16 | { 17 | name: "Remix Docs", 18 | url: "https://remix.run/docs", 19 | }, 20 | { 21 | name: "React Router Docs", 22 | url: "https://reactrouter.com/docs", 23 | }, 24 | { 25 | name: "Remix Discord", 26 | url: "https://discord.gg/VBePs6d", 27 | }, 28 | ], 29 | demos: [ 30 | { 31 | to: "demos/actions", 32 | name: "Actions", 33 | }, 34 | { 35 | to: "demos/about", 36 | name: "Nested Routes, CSS loading/unloading", 37 | }, 38 | { 39 | to: "demos/params", 40 | name: "URL Params and Error Boundaries", 41 | }, 42 | ], 43 | }; 44 | 45 | // https://remix.run/api/remix#json 46 | return json(data); 47 | }; 48 | 49 | // https://remix.run/api/conventions#meta 50 | export let meta: MetaFunction = () => { 51 | return { 52 | title: "Remix Starter", 53 | description: "Welcome to remix!", 54 | }; 55 | }; 56 | 57 | // https://remix.run/guides/routing#index-routes 58 | export default function Index() { 59 | let data = useLoaderData(); 60 | 61 | return ( 62 |
63 |
64 |

Welcome to Remix!

65 |

We're stoked that you're here. 🥳

66 |

67 | Feel free to take a look around the code to see how Remix does things, 68 | it might be a bit different than what you’re used to. When you're 69 | ready to dive deeper, we've got plenty of resources to get you 70 | up-and-running quickly. 71 |

72 |

73 | Check out all the demos in this starter, and then just delete the{" "} 74 | app/routes/demos and app/styles/demos{" "} 75 | folders when you're ready to turn this into your next project. 76 |

77 |
78 | 98 |
99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /server/createRequestHandler.ts: -------------------------------------------------------------------------------- 1 | import { URL } from "url"; 2 | import { 3 | Headers as NodeHeaders, 4 | Request as NodeRequest, 5 | formatServerError, 6 | } from "@remix-run/node"; 7 | import type { 8 | CloudFrontRequestEvent, 9 | CloudFrontRequestHandler, 10 | CloudFrontHeaders, 11 | } from "aws-lambda"; 12 | import type { 13 | AppLoadContext, 14 | ServerBuild, 15 | ServerPlatform, 16 | } from "@remix-run/server-runtime"; 17 | import { createRequestHandler as createRemixRequestHandler } from "@remix-run/server-runtime"; 18 | import type { Response as NodeResponse } from "@remix-run/node"; 19 | import { installGlobals } from "@remix-run/node"; 20 | 21 | installGlobals(); 22 | 23 | export interface GetLoadContextFunction { 24 | (event: CloudFrontRequestEvent): AppLoadContext; 25 | } 26 | 27 | export type RequestHandler = ReturnType; 28 | 29 | export function createRequestHandler({ 30 | build, 31 | getLoadContext, 32 | mode = process.env.NODE_ENV, 33 | }: { 34 | build: ServerBuild; 35 | getLoadContext?: GetLoadContextFunction; 36 | mode?: string; 37 | }): CloudFrontRequestHandler { 38 | let platform: ServerPlatform = { formatServerError }; 39 | let handleRequest = createRemixRequestHandler(build, platform, mode); 40 | 41 | return async (event, context) => { 42 | let request = createRemixRequest(event); 43 | 44 | let loadContext = 45 | typeof getLoadContext === "function" ? getLoadContext(event) : undefined; 46 | 47 | let response = (await handleRequest( 48 | request as unknown as Request, 49 | loadContext 50 | )) as unknown as NodeResponse; 51 | 52 | return { 53 | status: String(response.status), 54 | headers: createCloudFrontHeaders(response.headers), 55 | bodyEncoding: "text", 56 | body: await response.text(), 57 | }; 58 | }; 59 | } 60 | 61 | export function createCloudFrontHeaders( 62 | responseHeaders: NodeHeaders 63 | ): CloudFrontHeaders { 64 | let headers: CloudFrontHeaders = {}; 65 | let rawHeaders = responseHeaders.raw(); 66 | 67 | for (let key in rawHeaders) { 68 | let value = rawHeaders[key]; 69 | for (let v of value) { 70 | headers[key] = [...(headers[key] || []), { key, value: v }]; 71 | } 72 | } 73 | 74 | return headers; 75 | } 76 | 77 | export function createRemixHeaders( 78 | requestHeaders: CloudFrontHeaders 79 | ): NodeHeaders { 80 | let headers = new NodeHeaders(); 81 | 82 | for (let [key, values] of Object.entries(requestHeaders)) { 83 | for (let { value } of values) { 84 | if (value) { 85 | headers.append(key, value); 86 | } 87 | } 88 | } 89 | 90 | return headers; 91 | } 92 | 93 | export function createRemixRequest(event: CloudFrontRequestEvent): NodeRequest { 94 | let request = event.Records[0].cf.request; 95 | 96 | let host = request.headers["host"] 97 | ? request.headers["host"][0].value 98 | : undefined; 99 | let search = request.querystring.length ? `?${request.querystring}` : ""; 100 | let url = new URL(request.uri + search, `https://${host}`); 101 | 102 | return new NodeRequest(url.toString(), { 103 | method: request.method, 104 | headers: createRemixHeaders(request.headers), 105 | body: request.body?.data 106 | ? request.body.encoding === "base64" 107 | ? Buffer.from(request.body.data, "base64").toString() 108 | : request.body.data 109 | : undefined, 110 | }); 111 | } 112 | -------------------------------------------------------------------------------- /lib/cdk-remix-app-stack.ts: -------------------------------------------------------------------------------- 1 | import { Bucket } from "aws-cdk-lib/aws-s3"; 2 | import { 3 | BucketDeployment, 4 | CacheControl, 5 | Source, 6 | } from "aws-cdk-lib/aws-s3-deployment"; 7 | import { Construct } from "constructs"; 8 | import { Duration, RemovalPolicy, Stack, StackProps } from "aws-cdk-lib"; 9 | import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; 10 | import { RetentionDays } from "aws-cdk-lib/aws-logs"; 11 | import { S3Origin } from "aws-cdk-lib/aws-cloudfront-origins"; 12 | import { 13 | AllowedMethods, 14 | CachePolicy, 15 | Distribution, 16 | LambdaEdgeEventType, 17 | OriginAccessIdentity, 18 | OriginRequestCookieBehavior, 19 | OriginRequestHeaderBehavior, 20 | OriginRequestPolicy, 21 | OriginRequestQueryStringBehavior, 22 | ViewerProtocolPolicy, 23 | } from "aws-cdk-lib/aws-cloudfront"; 24 | 25 | export class CdkRemixAppStack extends Stack { 26 | constructor(scope: Construct, id: string, props?: StackProps) { 27 | super(scope, id, props); 28 | 29 | const assetsBucket = new Bucket(this, "AssetsBucket", { 30 | autoDeleteObjects: true, 31 | publicReadAccess: false, 32 | removalPolicy: RemovalPolicy.DESTROY, 33 | }); 34 | 35 | const assetsBucketOriginAccessIdentity = new OriginAccessIdentity( 36 | this, 37 | "AssetsBucketOriginAccessIdentity" 38 | ); 39 | 40 | const assetsBucketS3Origin = new S3Origin(assetsBucket, { 41 | originAccessIdentity: assetsBucketOriginAccessIdentity, 42 | }); 43 | 44 | assetsBucket.grantRead(assetsBucketOriginAccessIdentity); 45 | 46 | const edgeFn = new NodejsFunction(this, "EdgeFn", { 47 | currentVersionOptions: { 48 | removalPolicy: RemovalPolicy.DESTROY, 49 | }, 50 | entry: "server/index.ts", 51 | logRetention: RetentionDays.THREE_DAYS, 52 | memorySize: 1024, 53 | timeout: Duration.seconds(10), 54 | }); 55 | 56 | const distribution = new Distribution(this, "Distribution", { 57 | defaultBehavior: { 58 | allowedMethods: AllowedMethods.ALLOW_ALL, 59 | cachePolicy: CachePolicy.CACHING_DISABLED, 60 | compress: true, 61 | edgeLambdas: [ 62 | { 63 | eventType: LambdaEdgeEventType.ORIGIN_REQUEST, 64 | functionVersion: edgeFn.currentVersion, 65 | includeBody: true, 66 | }, 67 | ], 68 | origin: assetsBucketS3Origin, 69 | originRequestPolicy: new OriginRequestPolicy( 70 | this, 71 | "OriginRequestPolicy", 72 | { 73 | headerBehavior: OriginRequestHeaderBehavior.all(), 74 | queryStringBehavior: OriginRequestQueryStringBehavior.all(), 75 | cookieBehavior: OriginRequestCookieBehavior.all(), 76 | } 77 | ), 78 | viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, 79 | }, 80 | additionalBehaviors: { 81 | "build/*": { 82 | allowedMethods: AllowedMethods.ALLOW_GET_HEAD, 83 | cachePolicy: CachePolicy.CACHING_OPTIMIZED, 84 | compress: true, 85 | origin: assetsBucketS3Origin, 86 | viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, 87 | }, 88 | }, 89 | }); 90 | 91 | new BucketDeployment(this, "AssetsDeployment", { 92 | destinationBucket: assetsBucket, 93 | distribution, 94 | prune: true, 95 | sources: [Source.asset("public")], 96 | cacheControl: [ 97 | CacheControl.maxAge(Duration.days(365)), 98 | CacheControl.sMaxAge(Duration.days(365)), 99 | ], 100 | }); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /app/routes/demos/params/$id.tsx: -------------------------------------------------------------------------------- 1 | import { useCatch, Link, json, useLoaderData } from "remix"; 2 | import type { LoaderFunction, MetaFunction } from "remix"; 3 | 4 | // The `$` in route filenames becomes a pattern that's parsed from the URL and 5 | // passed to your loaders so you can look up data. 6 | // - https://remix.run/api/conventions#loader-params 7 | export let loader: LoaderFunction = async ({ params }) => { 8 | // pretend like we're using params.id to look something up in the db 9 | 10 | if (params.id === "this-record-does-not-exist") { 11 | // If the record doesn't exist we can't render the route normally, so 12 | // instead we throw a 404 reponse to stop running code here and show the 13 | // user the catch boundary. 14 | throw new Response("Not Found", { status: 404 }); 15 | } 16 | 17 | // now pretend like the record exists but the user just isn't authorized to 18 | // see it. 19 | if (params.id === "shh-its-a-secret") { 20 | // Again, we can't render the component if the user isn't authorized. You 21 | // can even put data in the response that might help the user rectify the 22 | // issue! Like emailing the webmaster for access to the page. (Oh, right, 23 | // `json` is just a Response helper that makes it easier to send JSON 24 | // responses). 25 | throw json({ webmasterEmail: "hello@remix.run" }, { status: 401 }); 26 | } 27 | 28 | // Sometimes your code just blows up and you never anticipated it. Remix will 29 | // automatically catch it and send the UI to the error boundary. 30 | if (params.id === "kaboom") { 31 | lol(); 32 | } 33 | 34 | // but otherwise the record was found, user has access, so we can do whatever 35 | // else we needed to in the loader and return the data. (This is boring, we're 36 | // just gonna return the params.id). 37 | return { param: params.id }; 38 | }; 39 | 40 | export default function ParamDemo() { 41 | let data = useLoaderData(); 42 | return ( 43 |

44 | The param is {data.param} 45 |

46 | ); 47 | } 48 | 49 | // https://remix.run/api/conventions#catchboundary 50 | // https://remix.run/api/remix#usecatch 51 | // https://remix.run/api/guides/not-found 52 | export function CatchBoundary() { 53 | let caught = useCatch(); 54 | 55 | let message: React.ReactNode; 56 | switch (caught.status) { 57 | case 401: 58 | message = ( 59 |

60 | Looks like you tried to visit a page that you do not have access to. 61 | Maybe ask the webmaster ({caught.data.webmasterEmail}) for access. 62 |

63 | ); 64 | case 404: 65 | message = ( 66 |

Looks like you tried to visit a page that does not exist.

67 | ); 68 | default: 69 | message = ( 70 |

71 | There was a problem with your request! 72 |
73 | {caught.status} {caught.statusText} 74 |

75 | ); 76 | } 77 | 78 | return ( 79 | <> 80 |

Oops!

81 |

{message}

82 |

83 | (Isn't it cool that the user gets to stay in context and try a different 84 | link in the parts of the UI that didn't blow up?) 85 |

86 | 87 | ); 88 | } 89 | 90 | // https://remix.run/api/conventions#errorboundary 91 | // https://remix.run/api/guides/not-found 92 | export function ErrorBoundary({ error }: { error: Error }) { 93 | console.error(error); 94 | return ( 95 | <> 96 |

Error!

97 |

{error.message}

98 |

99 | (Isn't it cool that the user gets to stay in context and try a different 100 | link in the parts of the UI that didn't blow up?) 101 |

102 | 103 | ); 104 | } 105 | 106 | export let meta: MetaFunction = ({ data }) => { 107 | return { 108 | title: data ? `Param: ${data.param}` : "Oops...", 109 | }; 110 | }; 111 | -------------------------------------------------------------------------------- /app/routes/demos/actions.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import type { ActionFunction } from "remix"; 3 | import { hash } from "~/utils.server"; 4 | import { Form, json, useActionData, useTransition, redirect } from "remix"; 5 | 6 | export function meta() { 7 | return { title: "Actions Demo" }; 8 | } 9 | 10 | // When your form sends a POST, the action is called on the server. 11 | // - https://remix.run/api/conventions#action 12 | // - https://remix.run/guides/data-updates 13 | export let action: ActionFunction = async ({ request }) => { 14 | let formData = await request.formData(); 15 | let answer = formData.get("answer"); 16 | 17 | // Typical action workflows start with validating the form data that just came 18 | // over the network. Clientside validation is fine, but you definitely need it 19 | // server side. If there's a problem, return the the data and the component 20 | // can render it. 21 | if (typeof answer !== "string") { 22 | return json("Come on, at least try!", { status: 400 }); 23 | } 24 | 25 | let rightAnswers = [ 26 | "4fa6024f12494d3a99d8bda9b7a55f7d140f328a", 27 | "ce3659ad235ca6d1e12dec21465aff3f9a62bb8c", 28 | "bd111dcb4b343de4ec0a79d2d5ec55a3919c79c4", 29 | ]; 30 | 31 | let encrypted = hash(answer); 32 | 33 | if (!rightAnswers.includes(encrypted)) { 34 | return json(`Sorry, ${answer} is not right.`, { status: 400 }); 35 | } 36 | 37 | // Finally, if the data is valid, you'll typically write to a database or send or 38 | // email or log the user in, etc. It's recommended to redirect after a 39 | // successful action, even if it's to the same place so that non-JavaScript workflows 40 | // from the browser doesn't repost the data if the user clicks back. 41 | return redirect("/demos/correct"); 42 | }; 43 | 44 | export default function ActionsDemo() { 45 | // https://remix.run/api/remix#useactiondata 46 | let actionMessage = useActionData(); 47 | let answerRef = useRef(null); 48 | 49 | // This form works without JavaScript, but when we have JavaScript we can make 50 | // the experience better by selecting the input on wrong answers! Go ahead, disable 51 | // JavaScript in your browser and see what happens. 52 | useEffect(() => { 53 | if (actionMessage && answerRef.current) { 54 | answerRef.current.select(); 55 | } 56 | }, [actionMessage]); 57 | 58 | return ( 59 |
60 |
61 |

Actions!

62 |

63 | This form submission will send a post request that we handle in our 64 | `action` export. Any route can export an action to handle data 65 | mutations. 66 |

67 |
68 |

Post an Action

69 |

70 | What is more useful when it is broken? 71 |

72 | 76 |
77 | 78 |
79 | {actionMessage ? ( 80 |

81 | {actionMessage} 82 |

83 | ) : null} 84 |
85 |
86 | 87 | 108 |
109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | Links, 4 | LiveReload, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | useCatch, 10 | Link, 11 | NavLink, 12 | } from "remix"; 13 | import type { LinksFunction } from "remix"; 14 | 15 | import deleteMeRemixStyles from "~/styles/demos/remix.css"; 16 | import globalStylesUrl from "~/styles/global.css"; 17 | import darkStylesUrl from "~/styles/dark.css"; 18 | 19 | import favicon from "~/images/favicon.png"; 20 | 21 | export let links: LinksFunction = () => { 22 | return [ 23 | { rel: "icon", href: favicon }, 24 | { rel: "stylesheet", href: globalStylesUrl }, 25 | { 26 | rel: "stylesheet", 27 | href: darkStylesUrl, 28 | media: "(prefers-color-scheme: dark)", 29 | }, 30 | { rel: "stylesheet", href: deleteMeRemixStyles }, 31 | ]; 32 | }; 33 | 34 | /** 35 | * The root module's default export is a component that renders the current 36 | * route via the `` component. Think of this as the global layout 37 | * component for your app. 38 | */ 39 | export default function App() { 40 | return ( 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | } 48 | 49 | function Document({ 50 | children, 51 | title, 52 | }: { 53 | children: React.ReactNode; 54 | title?: string; 55 | }) { 56 | return ( 57 | 58 | 59 | 60 | 61 | {title ? {title} : null} 62 | 63 | 64 | 65 | 66 | {children} 67 | 68 | 69 | {process.env.NODE_ENV === "development" && } 70 | 71 | 72 | ); 73 | } 74 | 75 | function Layout({ children }: React.PropsWithChildren<{}>) { 76 | return ( 77 |
78 |
79 |
80 | 81 | 82 | 83 | 96 |
97 |
98 |
99 |
{children}
100 |
101 |
102 |
103 |

© You!

104 |
105 |
106 |
107 | ); 108 | } 109 | 110 | export function CatchBoundary() { 111 | let caught = useCatch(); 112 | 113 | let message; 114 | switch (caught.status) { 115 | case 401: 116 | message = ( 117 |

118 | Oops! Looks like you tried to visit a page that you do not have access 119 | to. 120 |

121 | ); 122 | break; 123 | case 404: 124 | message = ( 125 |

Oops! Looks like you tried to visit a page that does not exist.

126 | ); 127 | break; 128 | 129 | default: 130 | throw new Error(caught.data || caught.statusText); 131 | } 132 | 133 | return ( 134 | 135 | 136 |

137 | {caught.status}: {caught.statusText} 138 |

139 | {message} 140 |
141 |
142 | ); 143 | } 144 | 145 | export function ErrorBoundary({ error }: { error: Error }) { 146 | console.error(error); 147 | return ( 148 | 149 | 150 |
151 |

There was an error

152 |

{error.message}

153 |
154 |

155 | Hey, developer, you should replace this with what you want your 156 | users to see. 157 |

158 |
159 |
160 |
161 | ); 162 | } 163 | 164 | function RemixLogo(props: React.ComponentPropsWithoutRef<"svg">) { 165 | return ( 166 | 178 | Remix Logo 179 | 180 | 181 | 182 | 183 | 184 | 185 | ); 186 | } 187 | --------------------------------------------------------------------------------