├── cypress.json ├── mocks ├── index.js ├── start.ts └── README.md ├── cypress ├── support │ ├── index.ts │ ├── delete-user.ts │ ├── create-user.ts │ └── commands.ts ├── .eslintrc.js ├── fixtures │ └── example.json ├── plugins │ └── index.ts ├── tsconfig.json └── e2e │ └── smoke.ts ├── .dockerignore ├── .env.example ├── public └── favicon.ico ├── .prettierignore ├── remix.env.d.ts ├── app ├── entry.client.tsx ├── routes │ ├── notes │ │ ├── index.tsx │ │ ├── $noteId.tsx │ │ └── new.tsx │ ├── logout.tsx │ ├── healthcheck.tsx │ ├── notes.tsx │ ├── join.tsx │ ├── login.tsx │ └── index.tsx ├── utils.test.ts ├── entry.server.tsx ├── db.server.ts ├── models │ ├── note.server.ts │ └── user.server.ts ├── root.tsx ├── utils.ts └── session.server.ts ├── tailwind.config.js ├── test └── setup-test-env.ts ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20220318151956_init │ │ └── migration.sql ├── schema.prisma └── seed.ts ├── .gitignore ├── remix.init ├── package.json ├── index.js └── package-lock.json ├── remix.config.js ├── vitest.config.ts ├── .eslintrc.js ├── tsconfig.json ├── README.md ├── Dockerfile └── package.json /cypress.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /mocks/index.js: -------------------------------------------------------------------------------- 1 | require("esbuild-register/dist/node").register(); 2 | require("./start"); 3 | -------------------------------------------------------------------------------- /cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/cypress/add-commands"; 2 | import "./commands"; 3 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/railwayapp-templates/indie-stack/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from "react-dom"; 2 | import { RemixBrowser } from "remix"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /cypress/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | tsconfigRootDir: __dirname, 4 | project: "./tsconfig.json", 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./app/**/*.{ts,tsx,jsx,js}"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | }; 8 | -------------------------------------------------------------------------------- /test/setup-test-env.ts: -------------------------------------------------------------------------------- 1 | import { installGlobals } from "@remix-run/node/globals"; 2 | import "@testing-library/jest-dom/extend-expect"; 3 | 4 | installGlobals(); 5 | -------------------------------------------------------------------------------- /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 = "postgresql" -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /.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.init/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix.init", 3 | "private": true, 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "sort-package-json": "^1.54.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /mocks/start.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | 3 | const server = setupServer(); 4 | 5 | server.listen({ onUnhandledRequest: "warn" }); 6 | console.info("🔶 Mock server running"); 7 | 8 | process.once("SIGINT", () => server.close()); 9 | process.once("SIGTERM", () => server.close()); 10 | -------------------------------------------------------------------------------- /app/routes/notes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "remix"; 2 | 3 | export default function NoteIndexPage() { 4 | return ( 5 |

6 | No note selected. Select a note on the left, or{" "} 7 | 8 | create a new note. 9 | 10 |

11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, LoaderFunction } from "remix"; 2 | import { redirect } from "remix"; 3 | import { logout } from "~/session.server"; 4 | 5 | export const action: ActionFunction = async ({ request }) => { 6 | return logout(request); 7 | }; 8 | 9 | export const loader: LoaderFunction = async () => { 10 | return redirect("/"); 11 | }; 12 | -------------------------------------------------------------------------------- /mocks/README.md: -------------------------------------------------------------------------------- 1 | # Mocks 2 | 3 | Use this to mock any third party HTTP resources that you don't have running locally and want to have mocked for local development as well as tests. 4 | 5 | Learn more about how to use this at [mswjs.io](https://mswjs.io/) 6 | 7 | For an extensive example, see the [source code for kentcdodds.com](https://github.com/kentcdodds/kentcdodds.com/blob/main/mocks/start.ts) 8 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import { defineConfig } from "vite"; 5 | import react from "@vitejs/plugin-react"; 6 | import tsconfigPaths from "vite-tsconfig-paths"; 7 | 8 | export default defineConfig({ 9 | plugins: [react(), tsconfigPaths()], 10 | test: { 11 | globals: true, 12 | environment: "happy-dom", 13 | setupFiles: ["./test/setup-test-env.ts"], 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /app/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { validateEmail } from "./utils"; 2 | 3 | test("validateEmail returns false for non-emails", () => { 4 | expect(validateEmail(undefined)).toBe(false); 5 | expect(validateEmail(null)).toBe(false); 6 | expect(validateEmail("")).toBe(false); 7 | expect(validateEmail("not-an-email")).toBe(false); 8 | expect(validateEmail("n@")).toBe(false); 9 | }); 10 | 11 | test("validateEmail returns true for emails", () => { 12 | expect(validateEmail("kody@example.com")).toBe(true); 13 | }); 14 | -------------------------------------------------------------------------------- /.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", 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 we have to explicitly 13 | // set the jest version. 14 | settings: { 15 | jest: { 16 | version: 27, 17 | }, 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 | const 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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["./cypress"], 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": "ES2020", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "target": "ES2019", 13 | "strict": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "~/*": ["./app/*"] 17 | }, 18 | "skipLibCheck": true, 19 | 20 | // Remix takes care of building everything in `remix build`. 21 | "noEmit": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /cypress/support/delete-user.ts: -------------------------------------------------------------------------------- 1 | // Use this to delete a user by their email 2 | // Simply call this with: 3 | // node --require esbuild-register ./cypress/support/delete-user.ts username@example.com 4 | // and that user will get deleted 5 | 6 | import { installGlobals } from "@remix-run/node/globals"; 7 | import { prisma } from "~/db.server"; 8 | 9 | installGlobals(); 10 | 11 | async function deleteUser(email: string) { 12 | if (!email) { 13 | throw new Error("email required for login"); 14 | } 15 | if (!email.endsWith("@example.com")) { 16 | throw new Error("All test emails must end in @example.com"); 17 | } 18 | 19 | await prisma.user.delete({ where: { email } }); 20 | } 21 | 22 | deleteUser(process.argv[2]); 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix example 2 | 3 | This example deploys self-hosted version of [Remix](https://remix.run/) using their [indie-stack](https://github.com/remix-run/indie-stack). Internally it uses a PostgreSQL database to store the data. 4 | 5 | [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/remix?referralCode=faraz) 6 | 7 | ## ✨ Features 8 | 9 | - Remix 10 | - TypeScript 11 | - PostgreSQL 12 | 13 | ## 💁‍♀️ How to use 14 | 15 | - Click the Railway button 👆 16 | - Add the `SESSION_SECRET` environment variable 17 | - You can hit CMD+K and use our secret generator 18 | 19 | ## 📝 Notes 20 | 21 | - [Official docs](https://remix.run/docs/en/v1) 22 | - A [video](https://youtu.be/1uebyGwAGvg) introducing the stack 23 | -------------------------------------------------------------------------------- /cypress/plugins/index.ts: -------------------------------------------------------------------------------- 1 | module.exports = ( 2 | on: Cypress.PluginEvents, 3 | config: Cypress.PluginConfigOptions 4 | ) => { 5 | const isDev = config.watchForFileChanges; 6 | const port = process.env.PORT ?? (isDev ? "3000" : "8811"); 7 | const configOverrides: Partial = { 8 | baseUrl: `http://localhost:${port}`, 9 | integrationFolder: "cypress/e2e", 10 | video: !process.env.CI, 11 | screenshotOnRunFailure: !process.env.CI, 12 | }; 13 | Object.assign(config, configOverrides); 14 | 15 | // To use this: 16 | // cy.task('log', whateverYouWantInTheTerminal) 17 | on("task", { 18 | log(message) { 19 | console.log(message); 20 | return null; 21 | }, 22 | }); 23 | 24 | return config; 25 | }; 26 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "../node_modules/@types/jest", 4 | "../node_modules/@testing-library/jest-dom" 5 | ], 6 | "include": [ 7 | "./index.ts", 8 | "e2e/**/*", 9 | "plugins/**/*", 10 | "support/**/*", 11 | "../node_modules/cypress", 12 | "../node_modules/@testing-library/cypress" 13 | ], 14 | "compilerOptions": { 15 | "baseUrl": ".", 16 | "noEmit": true, 17 | "types": ["node", "cypress", "@testing-library/cypress"], 18 | "esModuleInterop": true, 19 | "jsx": "react", 20 | "moduleResolution": "node", 21 | "target": "es2019", 22 | "strict": true, 23 | "skipLibCheck": true, 24 | "resolveJsonModule": true, 25 | "typeRoots": ["../types", "../node_modules/@types"], 26 | 27 | "paths": { 28 | "~/*": ["../app/*"] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/routes/healthcheck.tsx: -------------------------------------------------------------------------------- 1 | // learn more: https://fly.io/docs/reference/configuration/#services-http_checks 2 | import type { LoaderFunction } from "remix"; 3 | import { prisma } from "~/db.server"; 4 | 5 | export const loader: LoaderFunction = async ({ request }) => { 6 | const host = 7 | request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); 8 | 9 | try { 10 | // if we can connect to the database and make a simple query 11 | // and make a HEAD request to ourselves, then we're good. 12 | await Promise.all([ 13 | prisma.user.count(), 14 | fetch(`http://${host}`, { method: "HEAD" }).then((r) => { 15 | if (!r.ok) return Promise.reject(r); 16 | }), 17 | ]); 18 | return new Response("OK"); 19 | } catch (error: unknown) { 20 | console.log("healthcheck ❌", { error }); 21 | return new Response("ERROR", { status: 500 }); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "postgresql" 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 | notes Note[] 19 | } 20 | 21 | model Password { 22 | hash String 23 | 24 | user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) 25 | userId String @unique 26 | } 27 | 28 | model Note { 29 | id String @id @default(cuid()) 30 | title String 31 | body String 32 | 33 | createdAt DateTime @default(now()) 34 | updatedAt DateTime @updatedAt 35 | 36 | user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) 37 | userId String 38 | } 39 | -------------------------------------------------------------------------------- /app/models/note.server.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "~/db.server"; 2 | 3 | export function getNote({ userId, id }: { userId: string; id: string }) { 4 | return prisma.note.findFirst({ 5 | where: { id, userId }, 6 | }); 7 | } 8 | 9 | export function getNoteListItems({ userId }: { userId: string }) { 10 | return prisma.note.findMany({ 11 | where: { userId: userId }, 12 | select: { id: true, title: true }, 13 | orderBy: { updatedAt: "desc" }, 14 | }); 15 | } 16 | 17 | export function createNote({ 18 | title, 19 | body, 20 | userId, 21 | }: { 22 | title: string; 23 | body: string; 24 | userId: string; 25 | }) { 26 | return prisma.note.create({ 27 | data: { 28 | title, 29 | body, 30 | user: { 31 | connect: { 32 | id: userId, 33 | }, 34 | }, 35 | }, 36 | }); 37 | } 38 | 39 | export function deleteNote({ id, userId }: { id: string; userId: string }) { 40 | return prisma.note.deleteMany({ 41 | where: { id, userId }, 42 | }); 43 | } 44 | 45 | export type { Note } from "@prisma/client"; 46 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import bcrypt from "@node-rs/bcrypt"; 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("rachelrox", 10); 15 | 16 | const user = await prisma.user.create({ 17 | data: { 18 | email, 19 | password: { 20 | create: { 21 | hash: hashedPassword, 22 | }, 23 | }, 24 | }, 25 | }); 26 | 27 | await prisma.note.create({ 28 | data: { 29 | title: "My first note", 30 | body: "Hello, world!", 31 | userId: user.id, 32 | }, 33 | }); 34 | 35 | await prisma.note.create({ 36 | data: { 37 | title: "My second note", 38 | body: "Hello, world!", 39 | userId: user.id, 40 | }, 41 | }); 42 | 43 | console.log(`Database has been seeded. 🌱`); 44 | } 45 | 46 | seed() 47 | .catch((e) => { 48 | console.error(e); 49 | process.exit(1); 50 | }) 51 | .finally(async () => { 52 | await prisma.$disconnect(); 53 | }); 54 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | Links, 4 | LiveReload, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | } from "remix"; 10 | import type { LinksFunction, MetaFunction, LoaderFunction } from "remix"; 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 | type LoaderData = { 26 | user: Awaited>; 27 | }; 28 | 29 | export const loader: LoaderFunction = async ({ request }) => { 30 | return json({ 31 | user: await getUser(request), 32 | }); 33 | }; 34 | 35 | export default function App() { 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /prisma/migrations/20220318151956_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL, 4 | "email" TEXT NOT NULL, 5 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "updatedAt" TIMESTAMP(3) NOT NULL, 7 | 8 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 9 | ); 10 | 11 | -- CreateTable 12 | CREATE TABLE "Password" ( 13 | "hash" TEXT NOT NULL, 14 | "userId" TEXT NOT NULL 15 | ); 16 | 17 | -- CreateTable 18 | CREATE TABLE "Note" ( 19 | "id" TEXT NOT NULL, 20 | "title" TEXT NOT NULL, 21 | "body" TEXT NOT NULL, 22 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 23 | "updatedAt" TIMESTAMP(3) NOT NULL, 24 | "userId" TEXT NOT NULL, 25 | 26 | CONSTRAINT "Note_pkey" PRIMARY KEY ("id") 27 | ); 28 | 29 | -- CreateIndex 30 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 31 | 32 | -- CreateIndex 33 | CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId"); 34 | 35 | -- AddForeignKey 36 | ALTER TABLE "Password" ADD CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 37 | 38 | -- AddForeignKey 39 | ALTER TABLE "Note" ADD CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 40 | -------------------------------------------------------------------------------- /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 9 | 10 | # Install all node_modules, including dev dependencies 11 | FROM base as deps 12 | 13 | WORKDIR /myapp 14 | 15 | ADD package.json package-lock.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 package-lock.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 postinstall 39 | RUN npm run build 40 | 41 | # Run migrations 42 | ARG DATABASE_URL 43 | RUN npm run deploy:db 44 | 45 | # Finally, build the production image with minimal footprint 46 | FROM base 47 | 48 | WORKDIR /myapp 49 | 50 | COPY --from=production-deps /myapp/node_modules /myapp/node_modules 51 | COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma 52 | 53 | COPY --from=build /myapp/build /myapp/build 54 | COPY --from=build /myapp/public /myapp/public 55 | ADD . . 56 | 57 | CMD ["npm", "start"] 58 | -------------------------------------------------------------------------------- /app/models/user.server.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "@node-rs/bcrypt"; 2 | import { prisma } from "~/db.server"; 3 | 4 | export type { User } from "@prisma/client"; 5 | 6 | export async function getUserById(id: string) { 7 | return prisma.user.findUnique({ where: { id } }); 8 | } 9 | 10 | export async function getUserByEmail(email: string) { 11 | return prisma.user.findUnique({ where: { email } }); 12 | } 13 | 14 | export async function createUser(email: string, password: string) { 15 | const hashedPassword = await bcrypt.hash(password, 10); 16 | const user = await prisma.user.create({ 17 | data: { 18 | email, 19 | password: { 20 | create: { 21 | hash: hashedPassword, 22 | }, 23 | }, 24 | }, 25 | }); 26 | 27 | return user; 28 | } 29 | 30 | export async function deleteUserByEmail(email: string) { 31 | return prisma.user.delete({ where: { email } }); 32 | } 33 | 34 | export async function verifyLogin(email: string, password: string) { 35 | const userWithPassword = await prisma.user.findUnique({ 36 | where: { email }, 37 | include: { 38 | password: true, 39 | }, 40 | }); 41 | 42 | if (!userWithPassword || !userWithPassword.password) { 43 | return null; 44 | } 45 | 46 | const isValid = await bcrypt.verify(password, userWithPassword.password.hash); 47 | 48 | if (!isValid) { 49 | return null; 50 | } 51 | 52 | const { password: _password, ...userWithoutPassword } = userWithPassword; 53 | 54 | return userWithoutPassword; 55 | } 56 | -------------------------------------------------------------------------------- /app/utils.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useMatches } from "remix"; 3 | 4 | import type { User } from "~/models/user.server"; 5 | 6 | /** 7 | * This base hook is used in other hooks to quickly search for specific data 8 | * across all loader data using useMatches. 9 | * @param {string} id The route id 10 | * @returns {JSON|undefined} The router data or undefined if not found 11 | */ 12 | export function useMatchesData( 13 | id: string 14 | ): Record | undefined { 15 | const matchingRoutes = useMatches(); 16 | const route = useMemo( 17 | () => matchingRoutes.find((route) => route.id === id), 18 | [matchingRoutes, id] 19 | ); 20 | return route?.data; 21 | } 22 | 23 | function isUser(user: any): user is User { 24 | return user && typeof user === "object" && typeof user.email === "string"; 25 | } 26 | 27 | export function useOptionalUser(): User | undefined { 28 | const data = useMatchesData("root"); 29 | if (!data || !isUser(data.user)) { 30 | return undefined; 31 | } 32 | return data.user; 33 | } 34 | 35 | export function useUser(): User { 36 | const maybeUser = useOptionalUser(); 37 | if (!maybeUser) { 38 | throw new Error( 39 | "No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead." 40 | ); 41 | } 42 | return maybeUser; 43 | } 44 | 45 | export function validateEmail(email: unknown): email is string { 46 | return typeof email === "string" && email.length > 3 && email.includes("@"); 47 | } 48 | -------------------------------------------------------------------------------- /cypress/support/create-user.ts: -------------------------------------------------------------------------------- 1 | // Use this to create a new user and login with that user 2 | // Simply call this with: 3 | // node --require esbuild-register ./cypress/support/create-user.ts username@example.com 4 | // and it will log out the cookie value you can use to interact with the server 5 | // as that new user. 6 | 7 | import { parse } from "cookie"; 8 | import { installGlobals } from "@remix-run/node/globals"; 9 | import { createUserSession } from "~/session.server"; 10 | import { createUser } from "~/models/user.server"; 11 | 12 | installGlobals(); 13 | 14 | async function createAndLogin(email: string) { 15 | if (!email) { 16 | throw new Error("email required for login"); 17 | } 18 | if (!email.endsWith("@example.com")) { 19 | throw new Error("All test emails must end in @example.com"); 20 | } 21 | 22 | const user = await createUser(email, "myreallystrongpassword"); 23 | 24 | const response = await createUserSession({ 25 | request: new Request(""), 26 | userId: user.id, 27 | remember: false, 28 | redirectTo: "/", 29 | }); 30 | 31 | const cookieValue = response.headers.get("Set-Cookie"); 32 | if (!cookieValue) { 33 | throw new Error("Cookie missing from createUserSession response"); 34 | } 35 | const parsedCookie = parse(cookieValue); 36 | // we log it like this so our cypress command can parse it out and set it as 37 | // the cookie value. 38 | console.log( 39 | ` 40 | 41 | ${parsedCookie.__session} 42 | 43 | `.trim() 44 | ); 45 | } 46 | 47 | createAndLogin(process.argv[2]); 48 | -------------------------------------------------------------------------------- /cypress/e2e/smoke.ts: -------------------------------------------------------------------------------- 1 | import faker from "@faker-js/faker"; 2 | 3 | describe("smoke tests", () => { 4 | afterEach(() => { 5 | cy.cleanupUser(); 6 | }); 7 | 8 | it("should allow you to register and login", () => { 9 | const loginForm = { 10 | email: `${faker.internet.userName()}@example.com`, 11 | password: faker.internet.password(), 12 | }; 13 | cy.then(() => ({ email: loginForm.email })).as("user"); 14 | 15 | cy.visit("/"); 16 | cy.findByRole("link", { name: /sign up/i }).click(); 17 | 18 | cy.findByRole("textbox", { name: /email/i }).type(loginForm.email); 19 | cy.findByLabelText(/password/i).type(loginForm.password); 20 | cy.findByRole("button", { name: /create account/i }).click(); 21 | 22 | cy.findByRole("link", { name: /notes/i }).click(); 23 | cy.findByRole("button", { name: /logout/i }).click(); 24 | cy.findByRole("link", { name: /log in/i }); 25 | }); 26 | 27 | it("should allow you to make a note", () => { 28 | const testNote = { 29 | title: faker.lorem.words(1), 30 | body: faker.lorem.sentences(1), 31 | }; 32 | cy.login(); 33 | cy.visit("/"); 34 | 35 | cy.findByRole("link", { name: /notes/i }).click(); 36 | cy.findByText("No notes yet"); 37 | 38 | cy.findByRole("link", { name: /\+ new note/i }).click(); 39 | 40 | cy.findByRole("textbox", { name: /title/i }).type(testNote.title); 41 | cy.findByRole("textbox", { name: /body/i }).type(testNote.body); 42 | cy.findByRole("button", { name: /save/i }).click(); 43 | 44 | cy.findByRole("button", { name: /delete/i }).click(); 45 | 46 | cy.findByText("No notes yet"); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /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 sort = require("sort-package-json"); 7 | 8 | function escapeRegExp(string) { 9 | // $& means the whole matched string 10 | return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 11 | } 12 | 13 | function getRandomString(length) { 14 | return crypto.randomBytes(length).toString("hex"); 15 | } 16 | 17 | async function main({ rootDirectory }) { 18 | const README_PATH = path.join(rootDirectory, "README.md"); 19 | const EXAMPLE_ENV_PATH = path.join(rootDirectory, ".env.example"); 20 | const ENV_PATH = path.join(rootDirectory, ".env"); 21 | const PACKAGE_JSON_PATH = path.join(rootDirectory, "package.json"); 22 | 23 | const REPLACER = "indie-stack-template"; 24 | 25 | const DIR_NAME = path.basename(rootDirectory); 26 | const SUFFIX = getRandomString(2); 27 | const APP_NAME = DIR_NAME + "-" + SUFFIX; 28 | 29 | const [readme, env, packageJson] = await Promise.all([ 30 | fs.readFile(FLY_TOML_PATH, "utf-8"), 31 | fs.readFile(README_PATH, "utf-8"), 32 | fs.readFile(EXAMPLE_ENV_PATH, "utf-8"), 33 | fs.readFile(PACKAGE_JSON_PATH, "utf-8"), 34 | ]); 35 | 36 | const newEnv = env.replace( 37 | /^SESSION_SECRET=.*$/m, 38 | `SESSION_SECRET="${getRandomString(16)}"` 39 | ); 40 | 41 | const newReadme = readme.replace( 42 | new RegExp(escapeRegExp(REPLACER), "g"), 43 | APP_NAME 44 | ); 45 | 46 | const newPackageJson = 47 | JSON.stringify( 48 | sort({ ...JSON.parse(packageJson), name: APP_NAME }), 49 | null, 50 | 2 51 | ) + "\n"; 52 | 53 | await Promise.all([ 54 | fs.writeFile(README_PATH, newReadme), 55 | fs.writeFile(ENV_PATH, newEnv), 56 | fs.writeFile(PACKAGE_JSON_PATH, newPackageJson), 57 | ]); 58 | 59 | console.log( 60 | `Running the setup script to make sure everything was set up properly` 61 | ); 62 | execSync(`npm run setup`, { stdio: "inherit", cwd: rootDirectory }); 63 | 64 | console.log(`✅ Project is ready! Start development with "npm run dev"`); 65 | } 66 | 67 | module.exports = main; 68 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | import faker from "@faker-js/faker"; 2 | 3 | declare global { 4 | namespace Cypress { 5 | interface Chainable { 6 | /** 7 | * Logs in with a random user. Yields the user and adds an alias to the user 8 | * 9 | * @returns {typeof login} 10 | * @memberof Chainable 11 | * @example 12 | * cy.login() 13 | * @example 14 | * cy.login({ email: 'whatever@example.com' }) 15 | */ 16 | login: typeof login; 17 | 18 | /** 19 | * Deletes the current @user 20 | * 21 | * @returns {typeof cleanupUser} 22 | * @memberof Chainable 23 | * @example 24 | * cy.cleanupUser() 25 | * @example 26 | * cy.cleanupUser({ email: 'whatever@example.com' }) 27 | */ 28 | cleanupUser: typeof cleanupUser; 29 | } 30 | } 31 | } 32 | 33 | function login({ 34 | email = faker.internet.email(undefined, undefined, "example.com"), 35 | }: { 36 | email?: string; 37 | } = {}) { 38 | cy.then(() => ({ email })).as("user"); 39 | cy.exec( 40 | `node --require esbuild-register ./cypress/support/create-user.ts "${email}"` 41 | ).then(({ stdout }) => { 42 | const cookieValue = stdout 43 | .replace(/.*(?.*)<\/cookie>.*/s, "$") 44 | .trim(); 45 | cy.setCookie("__session", cookieValue); 46 | }); 47 | return cy.get("@user"); 48 | } 49 | 50 | function cleanupUser({ email }: { email?: string } = {}) { 51 | if (email) { 52 | deleteUserByEmail(email); 53 | } else { 54 | cy.get("@user").then((user) => { 55 | const email = (user as { email?: string }).email; 56 | if (email) { 57 | deleteUserByEmail(email); 58 | } 59 | }); 60 | } 61 | cy.clearCookie("__session"); 62 | } 63 | 64 | function deleteUserByEmail(email: string) { 65 | cy.exec( 66 | `node --require esbuild-register ./cypress/support/delete-user.ts "${email}"` 67 | ); 68 | cy.clearCookie("__session"); 69 | } 70 | 71 | Cypress.Commands.add("login", login); 72 | Cypress.Commands.add("cleanupUser", cleanupUser); 73 | 74 | /* 75 | eslint 76 | @typescript-eslint/no-namespace: "off", 77 | */ 78 | -------------------------------------------------------------------------------- /app/routes/notes/$noteId.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction, ActionFunction } from "remix"; 2 | import { redirect } from "remix"; 3 | import { json, useLoaderData, useCatch, Form } from "remix"; 4 | import invariant from "tiny-invariant"; 5 | import type { Note } from "~/models/note.server"; 6 | import { deleteNote } from "~/models/note.server"; 7 | import { getNote } from "~/models/note.server"; 8 | import { requireUserId } from "~/session.server"; 9 | 10 | type LoaderData = { 11 | note: Note; 12 | }; 13 | 14 | export const loader: LoaderFunction = async ({ request, params }) => { 15 | const userId = await requireUserId(request); 16 | invariant(params.noteId, "noteId not found"); 17 | 18 | const note = await getNote({ userId, id: params.noteId }); 19 | if (!note) { 20 | throw new Response("Not Found", { status: 404 }); 21 | } 22 | return json({ note }); 23 | }; 24 | 25 | export const action: ActionFunction = async ({ request, params }) => { 26 | const userId = await requireUserId(request); 27 | invariant(params.noteId, "noteId not found"); 28 | 29 | await deleteNote({ userId, id: params.noteId }); 30 | 31 | return redirect("/notes"); 32 | }; 33 | 34 | export default function NoteDetailsPage() { 35 | const data = useLoaderData() as LoaderData; 36 | 37 | return ( 38 |
39 |

{data.note.title}

40 |

{data.note.body}

41 |
42 |
43 | 49 |
50 |
51 | ); 52 | } 53 | 54 | export function ErrorBoundary({ error }: { error: Error }) { 55 | console.error(error); 56 | 57 | return
An unexpected error occurred: {error.message}
; 58 | } 59 | 60 | export function CatchBoundary() { 61 | const caught = useCatch(); 62 | 63 | if (caught.status === 404) { 64 | return
Note not found
; 65 | } 66 | 67 | throw new Error(`Unexpected caught response with status: ${caught.status}`); 68 | } 69 | -------------------------------------------------------------------------------- /app/routes/notes.tsx: -------------------------------------------------------------------------------- 1 | import { Form, json, useLoaderData, Outlet, Link, NavLink } from "remix"; 2 | import type { LoaderFunction } from "remix"; 3 | 4 | import { requireUserId } from "~/session.server"; 5 | import { useUser } from "~/utils"; 6 | import { getNoteListItems } from "~/models/note.server"; 7 | 8 | type LoaderData = { 9 | noteListItems: Awaited>; 10 | }; 11 | 12 | export const loader: LoaderFunction = async ({ request }) => { 13 | const userId = await requireUserId(request); 14 | const noteListItems = await getNoteListItems({ userId }); 15 | return json({ noteListItems }); 16 | }; 17 | 18 | export default function NotesPage() { 19 | const data = useLoaderData() as LoaderData; 20 | const user = useUser(); 21 | 22 | return ( 23 |
24 |
25 |

26 | Notes 27 |

28 |

{user.email}

29 |
30 | 36 |
37 |
38 | 39 |
40 |
41 | 42 | + New Note 43 | 44 | 45 |
46 | 47 | {data.noteListItems.length === 0 ? ( 48 |

No notes yet

49 | ) : ( 50 |
    51 | {data.noteListItems.map((note) => ( 52 |
  1. 53 | 55 | `block border-b p-4 text-xl ${isActive ? "bg-white" : ""}` 56 | } 57 | to={note.id} 58 | > 59 | 📝 {note.title} 60 | 61 |
  2. 62 | ))} 63 |
64 | )} 65 |
66 | 67 |
68 | 69 |
70 |
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /app/session.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage, redirect } from "remix"; 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 | maxAge: 0, 14 | path: "/", 15 | sameSite: "lax", 16 | secrets: [process.env.SESSION_SECRET], 17 | secure: process.env.NODE_ENV === "production", 18 | }, 19 | }); 20 | 21 | const USER_SESSION_KEY = "userId"; 22 | 23 | export async function getSession(request: Request) { 24 | const cookie = request.headers.get("Cookie"); 25 | return sessionStorage.getSession(cookie); 26 | } 27 | 28 | export async function getUserId(request: Request): Promise { 29 | const session = await getSession(request); 30 | const userId = session.get(USER_SESSION_KEY); 31 | return userId; 32 | } 33 | 34 | export async function getUser(request: Request): Promise { 35 | const userId = await getUserId(request); 36 | if (userId === undefined) return null; 37 | 38 | const user = await getUserById(userId); 39 | if (user) return user; 40 | 41 | throw await logout(request); 42 | } 43 | 44 | export async function requireUserId( 45 | request: Request, 46 | redirectTo: string = new URL(request.url).pathname 47 | ): Promise { 48 | const userId = await getUserId(request); 49 | if (!userId) { 50 | const searchParams = new URLSearchParams([["redirectTo", redirectTo]]); 51 | throw redirect(`/login?${searchParams}`); 52 | } 53 | return userId; 54 | } 55 | 56 | export async function requireUser(request: Request) { 57 | const userId = await requireUserId(request); 58 | 59 | const user = await getUserById(userId); 60 | if (user) return user; 61 | 62 | throw await logout(request); 63 | } 64 | 65 | export async function createUserSession({ 66 | request, 67 | userId, 68 | remember, 69 | redirectTo, 70 | }: { 71 | request: Request; 72 | userId: string; 73 | remember: boolean; 74 | redirectTo: string; 75 | }) { 76 | const session = await getSession(request); 77 | session.set(USER_SESSION_KEY, userId); 78 | return redirect(redirectTo, { 79 | headers: { 80 | "Set-Cookie": await sessionStorage.commitSession(session, { 81 | maxAge: remember 82 | ? 60 * 60 * 24 * 7 // 7 days 83 | : undefined, 84 | }), 85 | }, 86 | }); 87 | } 88 | 89 | export async function logout(request: Request) { 90 | const session = await getSession(request); 91 | return redirect("/", { 92 | headers: { 93 | "Set-Cookie": await sessionStorage.destroySession(session), 94 | }, 95 | }); 96 | } 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "indie-stack-template", 3 | "private": true, 4 | "description": "", 5 | "license": "", 6 | "sideEffects": false, 7 | "scripts": { 8 | "build": "run-s build:*", 9 | "build:css": "npm run generate:css -- --minify", 10 | "build:remix": "remix build", 11 | "dev": "run-p dev:*", 12 | "dev:css": "npm run generate:css -- --watch", 13 | "dev:remix": "node --require ./mocks ./node_modules/.bin/remix dev", 14 | "format": "prettier --write .", 15 | "generate:css": "tailwindcss -o ./app/styles/tailwind.css", 16 | "postinstall": "remix setup node", 17 | "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", 18 | "setup": "run-s setup:db setup:app", 19 | "setup:app": "run-p \"test -- --run\" lint typecheck test:e2e:run", 20 | "setup:db": "prisma migrate dev", 21 | "deploy:db": "prisma migrate deploy", 22 | "start": "remix-serve build", 23 | "start:mocks": "node --require ./mocks ./node_modules/.bin/remix-serve build", 24 | "test": "vitest", 25 | "test:e2e:dev": "start-server-and-test dev http://localhost:3000 'cypress open'", 26 | "pretest:e2e:run": "npm run build", 27 | "test:e2e:run": "cross-env PORT=8811 start-server-and-test start:mocks http://localhost:8811 'cypress run'", 28 | "typecheck": "tsc -b && tsc -b cypress" 29 | }, 30 | "prettier": {}, 31 | "eslintIgnore": [ 32 | "/node_modules", 33 | "/build", 34 | "/public/build" 35 | ], 36 | "dependencies": { 37 | "@node-rs/bcrypt": "^1.6.0", 38 | "@prisma/client": "^3.11.0", 39 | "@reach/alert": "^0.16.0", 40 | "@remix-run/react": "*", 41 | "@remix-run/serve": "*", 42 | "react": "^17.0.2", 43 | "react-dom": "^17.0.2", 44 | "remix": "*", 45 | "tiny-invariant": "^1.2.0" 46 | }, 47 | "devDependencies": { 48 | "@faker-js/faker": "^6.0.0", 49 | "@remix-run/dev": "*", 50 | "@remix-run/eslint-config": "*", 51 | "@testing-library/cypress": "^8.0.2", 52 | "@testing-library/jest-dom": "^5.16.2", 53 | "@testing-library/react": "^12.1.4", 54 | "@testing-library/user-event": "^13.5.0", 55 | "@types/eslint": "^8.4.1", 56 | "@types/react": "^17.0.40", 57 | "@types/react-dom": "^17.0.13", 58 | "@vitejs/plugin-react": "^1.2.0", 59 | "c8": "^7.11.0", 60 | "cross-env": "^7.0.3", 61 | "cypress": "^9.5.2", 62 | "esbuild-register": "^3.3.2", 63 | "eslint": "^8.11.0", 64 | "eslint-config-prettier": "^8.5.0", 65 | "happy-dom": "^2.49.0", 66 | "msw": "^0.39.2", 67 | "npm-run-all": "^4.1.5", 68 | "prettier": "2.6.0", 69 | "prettier-plugin-tailwindcss": "^0.1.8", 70 | "prisma": "^3.11.0", 71 | "start-server-and-test": "^1.14.0", 72 | "tailwindcss": "^3.0.23", 73 | "typescript": "^4.6.2", 74 | "vite-tsconfig-paths": "^3.4.1", 75 | "vitest": "^0.7.0" 76 | }, 77 | "engines": { 78 | "node": ">=14" 79 | }, 80 | "prisma": { 81 | "seed": "node --require esbuild-register prisma/seed.ts" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/routes/notes/new.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Form, json, redirect, useActionData } from "remix"; 3 | import type { ActionFunction } from "remix"; 4 | import Alert from "@reach/alert"; 5 | 6 | import { createNote } from "~/models/note.server"; 7 | import { requireUserId } from "~/session.server"; 8 | 9 | type ActionData = { 10 | errors?: { 11 | title?: string; 12 | body?: string; 13 | }; 14 | }; 15 | 16 | export const action: ActionFunction = async ({ request }) => { 17 | const userId = await requireUserId(request); 18 | 19 | const formData = await request.formData(); 20 | const title = formData.get("title"); 21 | const body = formData.get("body"); 22 | 23 | if (typeof title !== "string" || title.length === 0) { 24 | return json( 25 | { errors: { title: "Title is required" } }, 26 | { status: 400 } 27 | ); 28 | } 29 | 30 | if (typeof body !== "string" || body.length === 0) { 31 | return json( 32 | { errors: { body: "Body is required" } }, 33 | { status: 400 } 34 | ); 35 | } 36 | 37 | const note = await createNote({ title, body, userId }); 38 | 39 | return redirect(`/notes/${note.id}`); 40 | }; 41 | 42 | export default function NewNotePage() { 43 | const actionData = useActionData() as ActionData; 44 | const titleRef = React.useRef(null); 45 | const bodyRef = React.useRef(null); 46 | 47 | React.useEffect(() => { 48 | if (actionData?.errors?.title) { 49 | titleRef.current?.focus(); 50 | } else if (actionData?.errors?.body) { 51 | bodyRef.current?.focus(); 52 | } 53 | }, [actionData]); 54 | 55 | return ( 56 |
65 |
66 | 78 | {actionData?.errors?.title && ( 79 | 80 | {actionData.errors.title} 81 | 82 | )} 83 |
84 | 85 |
86 |