├── public
└── favicon.ico
├── .dockerignore
├── .env.example
├── .prettierignore
├── remix.env.d.ts
├── prisma
├── migrations
│ ├── migration_lock.toml
│ └── 20220714230702_init
│ │ └── migration.sql
├── schema.prisma
└── seed.ts
├── app
├── entry.client.tsx
├── routes
│ ├── logout.tsx
│ ├── index.tsx
│ └── login.tsx
├── db.server.ts
├── root.tsx
├── entry.server.tsx
├── models
│ └── user.server.ts
├── utils.ts
└── session.server.ts
├── tailwind.config.js
├── remix.init
├── gitignore
├── package.json
└── index.js
├── remix.config.js
├── start.sh
├── .gitignore
├── .eslintrc.js
├── tsconfig.json
├── fly.toml
├── Dockerfile
├── package.json
├── .github
└── workflows
│ └── deploy.yml
└── README.md
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kentcdodds/quick-stack/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | *.log
3 | .DS_Store
4 | .env
5 | /.cache
6 | /public/build
7 | /build
8 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | DATABASE_URL="file:./data.db?connection_limit=1"
2 | SESSION_SECRET="super-duper-s3cret"
3 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /build
4 | /public/build
5 | .env
6 |
7 | /app/styles/tailwind.css
8 |
--------------------------------------------------------------------------------
/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "sqlite"
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import { RemixBrowser } from "@remix-run/react";
2 | import { hydrateRoot } from "react-dom/client";
3 |
4 | hydrateRoot(document, );
5 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["./app/**/*.{ts,tsx,jsx,js}"],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | };
9 |
--------------------------------------------------------------------------------
/remix.init/gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /build
4 | /public/build
5 | .env
6 |
7 | /cypress/screenshots
8 | /cypress/videos
9 | /prisma/data.db
10 | /prisma/data.db-journal
11 |
12 | /app/styles/tailwind.css
13 |
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('@remix-run/dev').AppConfig}
3 | */
4 | module.exports = {
5 | cacheDirectory: "./node_modules/.cache/remix",
6 | ignoredRouteFiles: ["**/.*", "**/*.css", "**/*.test.{js,jsx,ts,tsx}"],
7 | };
8 |
--------------------------------------------------------------------------------
/remix.init/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remix.init",
3 | "private": true,
4 | "main": "index.js",
5 | "license": "MIT",
6 | "dependencies": {
7 | "@iarna/toml": "^2.2.5",
8 | "sort-package-json": "^1.57.0"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/app/routes/logout.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionArgs } from "@remix-run/node";
2 | import { redirect } from "@remix-run/node";
3 |
4 | import { logout } from "~/session.server";
5 |
6 | export async function action({ request }: ActionArgs) {
7 | return logout(request);
8 | }
9 |
10 | export async function loader() {
11 | return redirect("/");
12 | }
13 |
--------------------------------------------------------------------------------
/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # This file is how Fly starts the server (configured in fly.toml). Before starting
4 | # the server though, we need to run any prisma migrations that haven't yet been
5 | # run, which is why this file exists in the first place.
6 | # Learn more: https://community.fly.io/t/sqlite-not-getting-setup-properly/4386
7 |
8 | set -ex
9 | npx prisma migrate deploy
10 | npm run start
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # We don't want lockfiles in stacks, as people could use a different package manager
2 | # This part will be removed by `remix.init`
3 | package-lock.json
4 | yarn.lock
5 | pnpm-lock.yaml
6 | pnpm-lock.yml
7 |
8 | node_modules
9 |
10 | /build
11 | /public/build
12 | .env
13 |
14 | /cypress/screenshots
15 | /cypress/videos
16 | /prisma/data.db
17 | /prisma/data.db-journal
18 |
19 | /app/styles/tailwind.css
20 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('@types/eslint').Linter.BaseConfig}
3 | */
4 | module.exports = {
5 | extends: [
6 | "@remix-run/eslint-config",
7 | "@remix-run/eslint-config/node",
8 | "@remix-run/eslint-config/jest-testing-library",
9 | "prettier",
10 | ],
11 | // we're using vitest which has a very similar API to jest
12 | // (so the linting plugins work nicely), but it means we have to explicitly
13 | // set the jest version.
14 | settings: {
15 | jest: {
16 | version: 27,
17 | },
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | datasource db {
2 | provider = "sqlite"
3 | url = env("DATABASE_URL")
4 | }
5 |
6 | generator client {
7 | provider = "prisma-client-js"
8 | }
9 |
10 | model User {
11 | id String @id @default(cuid())
12 | email String @unique
13 |
14 | createdAt DateTime @default(now())
15 | updatedAt DateTime @updatedAt
16 |
17 | password Password?
18 | }
19 |
20 | model Password {
21 | hash String
22 |
23 | user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
24 | userId String @unique
25 | }
26 |
--------------------------------------------------------------------------------
/prisma/migrations/20220714230702_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "User" (
3 | "id" TEXT NOT NULL PRIMARY KEY,
4 | "email" TEXT NOT NULL,
5 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
6 | "updatedAt" DATETIME NOT NULL
7 | );
8 |
9 | -- CreateTable
10 | CREATE TABLE "Password" (
11 | "hash" TEXT NOT NULL,
12 | "userId" TEXT NOT NULL,
13 | CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
14 | );
15 |
16 | -- CreateIndex
17 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
18 |
19 | -- CreateIndex
20 | CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId");
21 |
--------------------------------------------------------------------------------
/app/db.server.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | let prisma: PrismaClient;
4 |
5 | declare global {
6 | var __db__: PrismaClient;
7 | }
8 |
9 | // this is needed because in development we don't want to restart
10 | // the server with every change, but we want to make sure we don't
11 | // create a new connection to the DB with every change either.
12 | // in production we'll have a single connection to the DB.
13 | if (process.env.NODE_ENV === "production") {
14 | prisma = new PrismaClient();
15 | } else {
16 | if (!global.__db__) {
17 | global.__db__ = new PrismaClient();
18 | }
19 | prisma = global.__db__;
20 | prisma.$connect();
21 | }
22 |
23 | export { prisma };
24 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "exclude": ["./cypress", "./cypress.config.ts"],
3 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
4 | "compilerOptions": {
5 | "lib": ["DOM", "DOM.Iterable", "ES2019"],
6 | "isolatedModules": true,
7 | "esModuleInterop": true,
8 | "jsx": "react-jsx",
9 | "module": "CommonJS",
10 | "moduleResolution": "node",
11 | "resolveJsonModule": true,
12 | "target": "ES2019",
13 | "strict": true,
14 | "allowJs": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "baseUrl": ".",
17 | "paths": {
18 | "~/*": ["./app/*"]
19 | },
20 | "skipLibCheck": true,
21 |
22 | // Remix takes care of building everything in `remix build`.
23 | "noEmit": true
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | app = "remix-quick-stack-template"
2 |
3 | kill_signal = "SIGINT"
4 | kill_timeout = 5
5 | processes = []
6 |
7 | [experimental]
8 | allowed_public_ports = []
9 | auto_rollback = true
10 | cmd = "start.sh"
11 | entrypoint = "sh"
12 |
13 | [mounts]
14 | source = "data"
15 | destination = "/data"
16 |
17 | [[services]]
18 | internal_port = 8080
19 | processes = ["app"]
20 | protocol = "tcp"
21 | script_checks = []
22 |
23 | [services.concurrency]
24 | hard_limit = 25
25 | soft_limit = 20
26 | type = "connections"
27 |
28 | [[services.ports]]
29 | handlers = ["http"]
30 | port = 80
31 | force_https = true
32 |
33 | [[services.ports]]
34 | handlers = ["tls", "http"]
35 | port = 443
36 |
37 | [[services.tcp_checks]]
38 | grace_period = "1s"
39 | interval = "15s"
40 | restart_limit = 0
41 | timeout = "2s"
42 |
--------------------------------------------------------------------------------
/prisma/seed.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 | import bcrypt from "bcryptjs";
3 |
4 | const prisma = new PrismaClient();
5 |
6 | async function seed() {
7 | const email = "rachel@remix.run";
8 |
9 | // cleanup the existing database
10 | await prisma.user.delete({ where: { email } }).catch(() => {
11 | // no worries if it doesn't exist yet
12 | });
13 |
14 | const hashedPassword = await bcrypt.hash("racheliscool", 10);
15 |
16 | await prisma.user.create({
17 | data: {
18 | email,
19 | password: {
20 | create: {
21 | hash: hashedPassword,
22 | },
23 | },
24 | },
25 | });
26 |
27 | console.log(`Database has been seeded. 🌱`);
28 | }
29 |
30 | seed()
31 | .catch((e) => {
32 | console.error(e);
33 | process.exit(1);
34 | })
35 | .finally(async () => {
36 | await prisma.$disconnect();
37 | });
38 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import type { LinksFunction, LoaderArgs, MetaFunction } from "@remix-run/node";
2 | import { json } from "@remix-run/node";
3 | import {
4 | Links,
5 | LiveReload,
6 | Meta,
7 | Outlet,
8 | Scripts,
9 | ScrollRestoration,
10 | } from "@remix-run/react";
11 |
12 | import tailwindStylesheetUrl from "./styles/tailwind.css";
13 | import { getUser } from "./session.server";
14 |
15 | export const links: LinksFunction = () => {
16 | return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
17 | };
18 |
19 | export const meta: MetaFunction = () => ({
20 | charset: "utf-8",
21 | title: "Remix Notes",
22 | viewport: "width=device-width,initial-scale=1",
23 | });
24 |
25 | export async function loader({ request }: LoaderArgs) {
26 | return json({
27 | user: await getUser(request),
28 | });
29 | }
30 |
31 | export default function App() {
32 | return (
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import { PassThrough } from "stream";
2 | import { renderToPipeableStream } from "react-dom/server";
3 | import { RemixServer } from "@remix-run/react";
4 | import { Response } from "@remix-run/node";
5 | import type { EntryContext, Headers } from "@remix-run/node";
6 | import isbot from "isbot";
7 |
8 | const ABORT_DELAY = 5000;
9 |
10 | export default function handleRequest(
11 | request: Request,
12 | responseStatusCode: number,
13 | responseHeaders: Headers,
14 | remixContext: EntryContext
15 | ) {
16 | const callbackName = isbot(request.headers.get("user-agent"))
17 | ? "onAllReady"
18 | : "onShellReady";
19 |
20 | return new Promise((resolve, reject) => {
21 | let didError = false;
22 |
23 | const { pipe, abort } = renderToPipeableStream(
24 | ,
25 | {
26 | [callbackName]() {
27 | let body = new PassThrough();
28 |
29 | responseHeaders.set("Content-Type", "text/html");
30 |
31 | resolve(
32 | new Response(body, {
33 | status: didError ? 500 : responseStatusCode,
34 | headers: responseHeaders,
35 | })
36 | );
37 | pipe(body);
38 | },
39 | onShellError(err: unknown) {
40 | reject(err);
41 | },
42 | onError(error: unknown) {
43 | didError = true;
44 | console.error(error);
45 | },
46 | }
47 | );
48 | setTimeout(abort, ABORT_DELAY);
49 | });
50 | }
51 |
--------------------------------------------------------------------------------
/app/models/user.server.ts:
--------------------------------------------------------------------------------
1 | import type { Password, User } from "@prisma/client";
2 | import bcrypt from "bcryptjs";
3 |
4 | import { prisma } from "~/db.server";
5 |
6 | export type { User } from "@prisma/client";
7 |
8 | export async function getUserById(id: User["id"]) {
9 | return prisma.user.findUnique({ where: { id } });
10 | }
11 |
12 | export async function getUserByEmail(email: User["email"]) {
13 | return prisma.user.findUnique({ where: { email } });
14 | }
15 |
16 | export async function createUser(email: User["email"], password: string) {
17 | const hashedPassword = await bcrypt.hash(password, 10);
18 |
19 | return prisma.user.create({
20 | data: {
21 | email,
22 | password: {
23 | create: {
24 | hash: hashedPassword,
25 | },
26 | },
27 | },
28 | });
29 | }
30 |
31 | export async function deleteUserByEmail(email: User["email"]) {
32 | return prisma.user.delete({ where: { email } });
33 | }
34 |
35 | export async function verifyLogin(
36 | email: User["email"],
37 | password: Password["hash"]
38 | ) {
39 | const userWithPassword = await prisma.user.findUnique({
40 | where: { email },
41 | include: {
42 | password: true,
43 | },
44 | });
45 |
46 | if (!userWithPassword || !userWithPassword.password) {
47 | return null;
48 | }
49 |
50 | const isValid = await bcrypt.compare(
51 | password,
52 | userWithPassword.password.hash
53 | );
54 |
55 | if (!isValid) {
56 | return null;
57 | }
58 |
59 | const { password: _password, ...userWithoutPassword } = userWithPassword;
60 |
61 | return userWithoutPassword;
62 | }
63 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # base node image
2 | FROM node:16-bullseye-slim as base
3 |
4 | # set for base and all layer that inherit from it
5 | ENV NODE_ENV production
6 |
7 | # Install openssl for Prisma
8 | RUN apt-get update && apt-get install -y openssl sqlite3
9 |
10 | # Install all node_modules, including dev dependencies
11 | FROM base as deps
12 |
13 | WORKDIR /myapp
14 |
15 | ADD package.json ./
16 | RUN npm install --production=false
17 |
18 | # Setup production node_modules
19 | FROM base as production-deps
20 |
21 | WORKDIR /myapp
22 |
23 | COPY --from=deps /myapp/node_modules /myapp/node_modules
24 | ADD package.json ./
25 | RUN npm prune --production
26 |
27 | # Build the app
28 | FROM base as build
29 |
30 | WORKDIR /myapp
31 |
32 | COPY --from=deps /myapp/node_modules /myapp/node_modules
33 |
34 | ADD prisma .
35 | RUN npx prisma generate
36 |
37 | ADD . .
38 | RUN npm run build
39 |
40 | # Finally, build the production image with minimal footprint
41 | FROM base
42 |
43 | ENV DATABASE_URL=file:/data/sqlite.db
44 | ENV PORT="8080"
45 | ENV NODE_ENV="production"
46 |
47 | # add shortcut for connecting to database CLI
48 | RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli
49 |
50 | WORKDIR /myapp
51 |
52 | COPY --from=production-deps /myapp/node_modules /myapp/node_modules
53 | COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma
54 |
55 | COPY --from=build /myapp/build /myapp/build
56 | COPY --from=build /myapp/public /myapp/public
57 | COPY --from=build /myapp/package.json /myapp/package.json
58 | COPY --from=build /myapp/start.sh /myapp/start.sh
59 | COPY --from=build /myapp/prisma /myapp/prisma
60 |
61 | ENTRYPOINT [ "./start.sh" ]
62 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remix-quick-stack-template",
3 | "private": true,
4 | "sideEffects": false,
5 | "scripts": {
6 | "build": "run-s build:*",
7 | "build:css": "npm run generate:css -- --minify",
8 | "build:remix": "remix build",
9 | "dev": "run-p dev:*",
10 | "dev:css": "npm run generate:css -- --watch",
11 | "dev:remix": "remix dev",
12 | "format": "prettier --write .",
13 | "generate:css": "tailwindcss -o ./app/styles/tailwind.css",
14 | "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
15 | "setup": "prisma generate && prisma migrate deploy && prisma db seed",
16 | "start": "remix-serve build",
17 | "typecheck": "tsc -b && tsc -b cypress",
18 | "validate": "run-p \"test -- --run\" lint typecheck"
19 | },
20 | "prettier": {},
21 | "eslintIgnore": [
22 | "/node_modules",
23 | "/build",
24 | "/public/build"
25 | ],
26 | "dependencies": {
27 | "@prisma/client": "^4.5.0",
28 | "@remix-run/node": "*",
29 | "@remix-run/react": "*",
30 | "@remix-run/serve": "*",
31 | "@remix-run/server-runtime": "*",
32 | "bcryptjs": "^2.4.3",
33 | "isbot": "^3.6.3",
34 | "react": "^18.2.0",
35 | "react-dom": "^18.2.0",
36 | "tiny-invariant": "^1.3.1"
37 | },
38 | "devDependencies": {
39 | "@remix-run/dev": "*",
40 | "@remix-run/eslint-config": "*",
41 | "@types/bcryptjs": "^2.4.2",
42 | "@types/eslint": "^8.4.10",
43 | "@types/node": "^18.11.9",
44 | "@types/react": "^18.0.25",
45 | "@types/react-dom": "^18.0.8",
46 | "@vitejs/plugin-react": "^2.2.0",
47 | "autoprefixer": "^10.4.13",
48 | "c8": "^7.12.0",
49 | "eslint": "^8.26.0",
50 | "eslint-config-prettier": "^8.5.0",
51 | "npm-run-all": "^4.1.5",
52 | "prettier": "2.7.1",
53 | "prettier-plugin-tailwindcss": "^0.1.13",
54 | "prisma": "^4.5.0",
55 | "tailwindcss": "^3.2.2",
56 | "ts-node": "^10.9.1",
57 | "tsconfig-paths": "^4.1.0",
58 | "typescript": "^4.8.4"
59 | },
60 | "engines": {
61 | "node": ">=14"
62 | },
63 | "prisma": {
64 | "seed": "ts-node --require tsconfig-paths/register prisma/seed.ts"
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/app/utils.ts:
--------------------------------------------------------------------------------
1 | import { useMatches } from "@remix-run/react";
2 | import { useMemo } from "react";
3 |
4 | import type { User } from "~/models/user.server";
5 |
6 | const DEFAULT_REDIRECT = "/";
7 |
8 | /**
9 | * This should be used any time the redirect path is user-provided
10 | * (Like the query string on our login/signup pages). This avoids
11 | * open-redirect vulnerabilities.
12 | * @param {string} to The redirect destination
13 | * @param {string} defaultRedirect The redirect to use if the to is unsafe.
14 | */
15 | export function safeRedirect(
16 | to: FormDataEntryValue | string | null | undefined,
17 | defaultRedirect: string = DEFAULT_REDIRECT
18 | ) {
19 | if (!to || typeof to !== "string") {
20 | return defaultRedirect;
21 | }
22 |
23 | if (!to.startsWith("/") || to.startsWith("//")) {
24 | return defaultRedirect;
25 | }
26 |
27 | return to;
28 | }
29 |
30 | /**
31 | * This base hook is used in other hooks to quickly search for specific data
32 | * across all loader data using useMatches.
33 | * @param {string} id The route id
34 | * @returns {JSON|undefined} The router data or undefined if not found
35 | */
36 | export function useMatchesData(
37 | id: string
38 | ): Record | undefined {
39 | const matchingRoutes = useMatches();
40 | const route = useMemo(
41 | () => matchingRoutes.find((route) => route.id === id),
42 | [matchingRoutes, id]
43 | );
44 | return route?.data;
45 | }
46 |
47 | function isUser(user: any): user is User {
48 | return user && typeof user === "object" && typeof user.email === "string";
49 | }
50 |
51 | export function useOptionalUser(): User | undefined {
52 | const data = useMatchesData("root");
53 | if (!data || !isUser(data.user)) {
54 | return undefined;
55 | }
56 | return data.user;
57 | }
58 |
59 | export function useUser(): User {
60 | const maybeUser = useOptionalUser();
61 | if (!maybeUser) {
62 | throw new Error(
63 | "No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead."
64 | );
65 | }
66 | return maybeUser;
67 | }
68 |
69 | export function validateEmail(email: unknown): email is string {
70 | return typeof email === "string" && email.length > 3 && email.includes("@");
71 | }
72 |
--------------------------------------------------------------------------------
/remix.init/index.js:
--------------------------------------------------------------------------------
1 | const { execSync } = require("child_process");
2 | const crypto = require("crypto");
3 | const fs = require("fs/promises");
4 | const path = require("path");
5 |
6 | const toml = require("@iarna/toml");
7 | const sort = require("sort-package-json");
8 |
9 | function escapeRegExp(string) {
10 | // $& means the whole matched string
11 | return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12 | }
13 |
14 | function getRandomString(length) {
15 | return crypto.randomBytes(length).toString("hex");
16 | }
17 |
18 | async function main({ rootDirectory }) {
19 | const README_PATH = path.join(rootDirectory, "README.md");
20 | const FLY_TOML_PATH = path.join(rootDirectory, "fly.toml");
21 | const EXAMPLE_ENV_PATH = path.join(rootDirectory, ".env.example");
22 | const ENV_PATH = path.join(rootDirectory, ".env");
23 | const PACKAGE_JSON_PATH = path.join(rootDirectory, "package.json");
24 |
25 | const REPLACER = "remix-quick-stack-template";
26 |
27 | const DIR_NAME = path.basename(rootDirectory);
28 | const SUFFIX = getRandomString(2);
29 |
30 | const APP_NAME = (DIR_NAME + "-" + SUFFIX)
31 | // get rid of anything that's not allowed in an app name
32 | .replace(/[^a-zA-Z0-9-_]/g, "-");
33 |
34 | const [prodContent, readme, env, packageJson] = await Promise.all([
35 | fs.readFile(FLY_TOML_PATH, "utf-8"),
36 | fs.readFile(README_PATH, "utf-8"),
37 | fs.readFile(EXAMPLE_ENV_PATH, "utf-8"),
38 | fs.readFile(PACKAGE_JSON_PATH, "utf-8"),
39 | ]);
40 |
41 | const newEnv = env.replace(
42 | /^SESSION_SECRET=.*$/m,
43 | `SESSION_SECRET="${getRandomString(16)}"`
44 | );
45 |
46 | const prodToml = toml.parse(prodContent);
47 | prodToml.app = prodToml.app.replace(REPLACER, APP_NAME);
48 |
49 | const newReadme = readme.replace(
50 | new RegExp(escapeRegExp(REPLACER), "g"),
51 | APP_NAME
52 | );
53 |
54 | const newPackageJson =
55 | JSON.stringify(
56 | sort({ ...JSON.parse(packageJson), name: APP_NAME }),
57 | null,
58 | 2
59 | ) + "\n";
60 |
61 | await Promise.all([
62 | fs.writeFile(FLY_TOML_PATH, toml.stringify(prodToml)),
63 | fs.writeFile(README_PATH, newReadme),
64 | fs.writeFile(ENV_PATH, newEnv),
65 | fs.writeFile(PACKAGE_JSON_PATH, newPackageJson),
66 | fs.copyFile(
67 | path.join(rootDirectory, "remix.init", "gitignore"),
68 | path.join(rootDirectory, ".gitignore")
69 | ),
70 | ]);
71 |
72 | execSync(`npm run setup`, { stdio: "inherit", cwd: rootDirectory });
73 |
74 | console.log(
75 | `Setup is complete. You're now ready to rock and roll 🤘
76 |
77 | Start development with \`npm run dev\`
78 | `.trim()
79 | );
80 | }
81 |
82 | module.exports = main;
83 |
--------------------------------------------------------------------------------
/app/session.server.ts:
--------------------------------------------------------------------------------
1 | import { createCookieSessionStorage, redirect } from "@remix-run/node";
2 | import invariant from "tiny-invariant";
3 |
4 | import type { User } from "~/models/user.server";
5 | import { getUserById } from "~/models/user.server";
6 |
7 | invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set");
8 |
9 | export const sessionStorage = createCookieSessionStorage({
10 | cookie: {
11 | name: "__session",
12 | httpOnly: true,
13 | path: "/",
14 | sameSite: "lax",
15 | secrets: [process.env.SESSION_SECRET],
16 | secure: process.env.NODE_ENV === "production",
17 | },
18 | });
19 |
20 | const USER_SESSION_KEY = "userId";
21 |
22 | export async function getSession(request: Request) {
23 | const cookie = request.headers.get("Cookie");
24 | return sessionStorage.getSession(cookie);
25 | }
26 |
27 | export async function getUserId(
28 | request: Request
29 | ): Promise {
30 | const session = await getSession(request);
31 | const userId = session.get(USER_SESSION_KEY);
32 | return userId;
33 | }
34 |
35 | export async function getUser(request: Request) {
36 | const userId = await getUserId(request);
37 | if (userId === undefined) return null;
38 |
39 | const user = await getUserById(userId);
40 | if (user) return user;
41 |
42 | throw await logout(request);
43 | }
44 |
45 | export async function requireUserId(
46 | request: Request,
47 | redirectTo: string = new URL(request.url).pathname
48 | ) {
49 | const userId = await getUserId(request);
50 | if (!userId) {
51 | const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
52 | throw redirect(`/login?${searchParams}`);
53 | }
54 | return userId;
55 | }
56 |
57 | export async function requireUser(request: Request) {
58 | const userId = await requireUserId(request);
59 |
60 | const user = await getUserById(userId);
61 | if (user) return user;
62 |
63 | throw await logout(request);
64 | }
65 |
66 | export async function createUserSession({
67 | request,
68 | userId,
69 | remember,
70 | redirectTo,
71 | }: {
72 | request: Request;
73 | userId: string;
74 | remember: boolean;
75 | redirectTo: string;
76 | }) {
77 | const session = await getSession(request);
78 | session.set(USER_SESSION_KEY, userId);
79 | return redirect(redirectTo, {
80 | headers: {
81 | "Set-Cookie": await sessionStorage.commitSession(session, {
82 | maxAge: remember
83 | ? 60 * 60 * 24 * 7 // 7 days
84 | : undefined,
85 | }),
86 | },
87 | });
88 | }
89 |
90 | export async function logout(request: Request) {
91 | const session = await getSession(request);
92 | return redirect("/", {
93 | headers: {
94 | "Set-Cookie": await sessionStorage.destroySession(session),
95 | },
96 | });
97 | }
98 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: 🚀 Deploy
2 | on:
3 | push:
4 | branches:
5 | - main
6 | - dev
7 | pull_request: {}
8 | permissions:
9 | actions: write
10 | contents: read
11 |
12 | jobs:
13 | build:
14 | name: 🐳 Build
15 | # only build/deploy main branch on pushes
16 | if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }}
17 | runs-on: ubuntu-latest
18 | steps:
19 | - name: ⬇️ Checkout repo
20 | uses: actions/checkout@v3
21 |
22 | - name: 👀 Read app name
23 | uses: SebRollen/toml-action@v1.0.0
24 | id: app_name
25 | with:
26 | file: "fly.toml"
27 | field: "app"
28 |
29 | - name: 🐳 Set up Docker Buildx
30 | uses: docker/setup-buildx-action@v2
31 |
32 | # Setup cache
33 | - name: ⚡️ Cache Docker layers
34 | uses: actions/cache@v3
35 | with:
36 | path: /tmp/.buildx-cache
37 | key: ${{ runner.os }}-buildx-${{ github.sha }}
38 | restore-keys: |
39 | ${{ runner.os }}-buildx-
40 |
41 | - name: 🔑 Fly Registry Auth
42 | uses: docker/login-action@v2
43 | with:
44 | registry: registry.fly.io
45 | username: x
46 | password: ${{ secrets.FLY_API_TOKEN }}
47 |
48 | - name: 🐳 Docker build
49 | uses: docker/build-push-action@v3
50 | with:
51 | context: .
52 | push: true
53 | tags: registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}
54 | build-args: |
55 | COMMIT_SHA=${{ github.sha }}
56 | cache-from: type=local,src=/tmp/.buildx-cache
57 | cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
58 |
59 | # This ugly bit is necessary if you don't want your cache to grow forever
60 | # till it hits GitHub's limit of 5GB.
61 | # Temp fix
62 | # https://github.com/docker/build-push-action/issues/252
63 | # https://github.com/moby/buildkit/issues/1896
64 | - name: 🚚 Move cache
65 | run: |
66 | rm -rf /tmp/.buildx-cache
67 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache
68 |
69 | deploy:
70 | name: 🚀 Deploy
71 | runs-on: ubuntu-latest
72 | needs: [build]
73 | # only build/deploy main branch on pushes
74 | if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }}
75 |
76 | steps:
77 | - name: ⬇️ Checkout repo
78 | uses: actions/checkout@v3
79 |
80 | - name: 👀 Read app name
81 | uses: SebRollen/toml-action@v1.0.0
82 | id: app_name
83 | with:
84 | file: "fly.toml"
85 | field: "app"
86 |
87 | - name: 🚀 Deploy Production
88 | if: ${{ github.ref == 'refs/heads/main' }}
89 | uses: superfly/flyctl-actions@1.3
90 | with:
91 | args: "deploy --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}"
92 | env:
93 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
94 |
--------------------------------------------------------------------------------
/app/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import { Form, Link } from "@remix-run/react";
2 |
3 | import { useOptionalUser } from "~/utils";
4 |
5 | export default function Index() {
6 | const user = useOptionalUser();
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
18 |
19 |
20 |
21 |
22 |
23 | KCD Quick Stack
24 |
25 |
26 |
27 | Check the README.md file for instructions on how to get this
28 | project deployed.
29 |
30 |
31 | {user ? (
32 |
37 | ) : (
38 |
42 | Log In
43 |
44 | )}
45 |
46 |
47 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | {[
60 | {
61 | src: "https://user-images.githubusercontent.com/1500684/157764397-ccd8ea10-b8aa-4772-a99b-35de937319e1.svg",
62 | alt: "Fly.io",
63 | href: "https://fly.io",
64 | },
65 | {
66 | src: "https://user-images.githubusercontent.com/1500684/157764395-137ec949-382c-43bd-a3c0-0cb8cb22e22d.svg",
67 | alt: "SQLite",
68 | href: "https://sqlite.org",
69 | },
70 | {
71 | src: "https://user-images.githubusercontent.com/1500684/157764484-ad64a21a-d7fb-47e3-8669-ec046da20c1f.svg",
72 | alt: "Prisma",
73 | href: "https://prisma.io",
74 | },
75 | {
76 | src: "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg",
77 | alt: "Tailwind",
78 | href: "https://tailwindcss.com",
79 | },
80 | {
81 | src: "https://user-images.githubusercontent.com/1500684/157772934-ce0a943d-e9d0-40f8-97f3-f464c0811643.svg",
82 | alt: "Prettier",
83 | href: "https://prettier.io",
84 | },
85 | {
86 | src: "https://user-images.githubusercontent.com/1500684/157772990-3968ff7c-b551-4c55-a25c-046a32709a8e.svg",
87 | alt: "ESLint",
88 | href: "https://eslint.org",
89 | },
90 | {
91 | src: "https://user-images.githubusercontent.com/1500684/157773063-20a0ed64-b9f8-4e0b-9d1e-0b65a3d4a6db.svg",
92 | alt: "TypeScript",
93 | href: "https://typescriptlang.org",
94 | },
95 | ].map((img) => (
96 |
101 |
102 |
103 | ))}
104 |
105 |
106 |
107 |
108 | );
109 | }
110 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # KCD Quick Stack
2 |
3 | 
4 |
5 | The primary use of this stack is for Kent to quickly setup new Remix apps that have no more than the bare necessities.
6 |
7 | Learn more about [Remix Stacks](https://remix.run/stacks).
8 |
9 | ```
10 | npx create-remix --template kentcdodds/quick-stack
11 | ```
12 |
13 | ## What's in the stack
14 |
15 | - [Fly app deployment](https://fly.io) with [Docker](https://www.docker.com/)
16 | - Production-ready [SQLite Database](https://sqlite.org)
17 | - [GitHub Actions](https://github.com/features/actions) for deploy on merge to production
18 | - Email/Password Authentication with [cookie-based sessions](https://remix.run/docs/en/v1/api/remix#createcookiesessionstorage)
19 | - Database ORM with [Prisma](https://prisma.io)
20 | - Styling with [Tailwind](https://tailwindcss.com/)
21 | - Code formatting with [Prettier](https://prettier.io)
22 | - Linting with [ESLint](https://eslint.org)
23 | - Static Types with [TypeScript](https://typescriptlang.org)
24 |
25 | Not a fan of bits of the stack? Fork it, change it, and use `npx create-remix --template your/repo`! Make it your own.
26 |
27 | ## Development
28 |
29 | - This step only applies if you've opted out of having the CLI install dependencies for you:
30 |
31 | ```sh
32 | npx remix init
33 | ```
34 |
35 | - Initial setup: _If you just generated this project, this step has been done for you._
36 |
37 | ```sh
38 | npm run setup
39 | ```
40 |
41 | - Start dev server:
42 |
43 | ```sh
44 | npm run dev
45 | ```
46 |
47 | This starts your app in development mode, rebuilding assets on file changes.
48 |
49 | The database seed script creates a new user with some data you can use to get started:
50 |
51 | - Email: `rachel@remix.run`
52 | - Password: `racheliscool`
53 |
54 | ### Relevant code:
55 |
56 | This app does nothing. You can login and logout. That's it.
57 |
58 | - creating users, and logging in and out [./app/models/user.server.ts](./app/models/user.server.ts)
59 | - user sessions, and verifying them [./app/session.server.ts](./app/session.server.ts)
60 |
61 | ## Deployment
62 |
63 | This Remix Stack comes with two GitHub Actions that handle automatically deploying your app to production.
64 |
65 | Prior to your first deployment, you'll need to do a few things:
66 |
67 | - [Install Fly](https://fly.io/docs/getting-started/installing-flyctl/)
68 |
69 | - Sign up and log in to Fly
70 |
71 | ```sh
72 | fly auth signup
73 | ```
74 |
75 | > **Note:** If you have more than one Fly account, ensure that you are signed into the same account in the Fly CLI as you are in the browser. In your terminal, run `fly auth whoami` and ensure the email matches the Fly account signed into the browser.
76 |
77 | - Create two apps on Fly, one for production:
78 |
79 | ```sh
80 | fly create remix-quick-stack-template
81 | ```
82 |
83 | > **Note:** Make sure this name matches the `app` set in your `fly.toml` file. Otherwise, you will not be able to deploy.
84 |
85 | - Initialize Git.
86 |
87 | ```sh
88 | git init
89 | ```
90 |
91 | - Create a new [GitHub Repository](https://repo.new), and then add it as the remote for your project. **Do not push your app yet!**
92 |
93 | ```sh
94 | git remote add origin
95 | ```
96 |
97 | - Add a `FLY_API_TOKEN` to your GitHub repo. To do this, go to your user settings on Fly and create a new [token](https://web.fly.io/user/personal_access_tokens/new), then add it to [your repo secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) with the name `FLY_API_TOKEN`.
98 |
99 | - Add a `SESSION_SECRET` to your fly app secrets, to do this you can run the following command:
100 |
101 | ```sh
102 | fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app remix-quick-stack-template
103 | ```
104 |
105 | If you don't have openssl installed, you can also use [1password](https://1password.com/password-generator/) to generate a random secret, just replace `$(openssl rand -hex 32)` with the generated secret.
106 |
107 | - Create a persistent volume for the sqlite database for your production environment. Run the following:
108 |
109 | ```sh
110 | fly volumes create data --size 1 --app remix-quick-stack-template
111 | ```
112 |
113 | Now that everything is set up you can commit and push your changes to your repo. Every commit to your `main` branch will trigger a deployment to your production environment.
114 |
115 | ### Connecting to your database
116 |
117 | The sqlite database lives at `/data/sqlite.db` in your deployed application. You can connect to the live database by running `fly ssh console -C database-cli`.
118 |
119 | ### Getting Help with Deployment
120 |
121 | If you run into any issues deploying to Fly, make sure you've followed all of the steps above and if you have, then post as many details about your deployment (including your app name) to [the Fly support community](https://community.fly.io). They're normally pretty responsive over there and hopefully can help resolve any of your deployment issues and questions.
122 |
123 | ## GitHub Actions
124 |
125 | We use GitHub Actions for continuous integration and deployment. Anything that gets into the `main` branch will be deployed to production after running the build (we do not run linting/typescript in CI... This is quick remember?).
126 |
127 | ### Type Checking
128 |
129 | This project uses TypeScript. It's recommended to get TypeScript set up for your editor to get a really great in-editor experience with type checking and auto-complete. To run type checking across the whole project, run `npm run typecheck`.
130 |
131 | ### Linting
132 |
133 | This project uses ESLint for linting. That is configured in `.eslintrc.js`.
134 |
135 | ### Formatting
136 |
137 | We use [Prettier](https://prettier.io/) for auto-formatting in this project. It's recommended to install an editor plugin (like the [VSCode Prettier plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)) to get auto-formatting on save. There's also a `npm run format` script you can run to format all files in the project.
138 |
--------------------------------------------------------------------------------
/app/routes/login.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionArgs, LoaderArgs, MetaFunction } from "@remix-run/node";
2 | import { json, redirect } from "@remix-run/node";
3 | import { Form, useActionData, useSearchParams } from "@remix-run/react";
4 | import * as React from "react";
5 | import { createUserSession, getUserId } from "~/session.server";
6 | import { createUser, getUserByEmail, verifyLogin } from "~/models/user.server";
7 | import { safeRedirect, validateEmail } from "~/utils";
8 |
9 | export async function loader({ request }: LoaderArgs) {
10 | const userId = await getUserId(request);
11 | if (userId) return redirect("/");
12 | return json({});
13 | }
14 |
15 | export async function action({ request }: ActionArgs) {
16 | const formData = await request.formData();
17 | const email = formData.get("email");
18 | const password = formData.get("password");
19 | const redirectTo = safeRedirect(formData.get("redirectTo"), "/");
20 | const remember = formData.get("remember");
21 |
22 | if (!validateEmail(email)) {
23 | return json({ errors: { email: "Email is invalid" } }, { status: 400 });
24 | }
25 |
26 | if (typeof password !== "string" || password.length === 0) {
27 | return json(
28 | { errors: { password: "Password is required" } },
29 | { status: 400 }
30 | );
31 | }
32 |
33 | if (password.length < 8) {
34 | return json(
35 | { errors: { password: "Password is too short" } },
36 | { status: 400 }
37 | );
38 | }
39 |
40 | const intent = formData.get("intent");
41 | let userId: string;
42 | switch (intent) {
43 | case "login": {
44 | const user = await verifyLogin(email, password);
45 |
46 | if (!user) {
47 | return json(
48 | { errors: { email: "Invalid email or password" } },
49 | { status: 400 }
50 | );
51 | }
52 | userId = user.id;
53 | break;
54 | }
55 | case "signup": {
56 | const existingUser = await getUserByEmail(email);
57 | if (existingUser) {
58 | return json(
59 | { errors: { email: "A user already exists with this email" } },
60 | { status: 400 }
61 | );
62 | }
63 |
64 | const user = await createUser(email, password);
65 | userId = user.id;
66 |
67 | break;
68 | }
69 | default: {
70 | return json({ errors: { email: "Invalid intent" } }, { status: 400 });
71 | }
72 | }
73 |
74 | return createUserSession({
75 | request,
76 | userId,
77 | remember: remember === "on" ? true : false,
78 | redirectTo,
79 | });
80 | }
81 |
82 | export const meta: MetaFunction = () => {
83 | return {
84 | title: "Login",
85 | };
86 | };
87 |
88 | export default function LoginPage() {
89 | const [searchParams] = useSearchParams();
90 | const redirectTo = searchParams.get("redirectTo") || "/";
91 | const actionData = useActionData();
92 | const emailRef = React.useRef(null);
93 | const passwordRef = React.useRef(null);
94 | let emailError: string | null = null;
95 | let passwordError: string | null = null;
96 | if (actionData && actionData.errors) {
97 | const { errors } = actionData;
98 | emailError = "email" in errors ? errors.email : null;
99 | passwordError = "password" in errors ? errors.password : null;
100 | }
101 |
102 | React.useEffect(() => {
103 | if (emailError) {
104 | emailRef.current?.focus();
105 | } else if (passwordError) {
106 | passwordRef.current?.focus();
107 | }
108 | }, [emailError, passwordError]);
109 |
110 | return (
111 |
206 | );
207 | }
208 |
--------------------------------------------------------------------------------