3 |
4 | Ditch analysis paralysis and start shipping Epic Web apps.
5 |
6 |
7 | This is an opinionated project starter and reference that allows teams to
8 | ship their ideas to production faster and on a more stable foundation based
9 | on the experience of Kent C. Dodds and
10 | contributors.
11 |
12 |
13 |
14 | ```sh
15 | npx create-epic-app@latest
16 | ```
17 |
18 | [](https://www.epicweb.dev/epic-stack)
19 |
20 | [The Epic Stack](https://www.epicweb.dev/epic-stack)
21 |
22 |
23 |
24 | ## Watch Kent's Introduction to The Epic Stack
25 |
26 | [](https://www.epicweb.dev/talks/the-epic-stack)
27 |
28 | ["The Epic Stack" by Kent C. Dodds](https://www.epicweb.dev/talks/the-epic-stack)
29 |
30 | ## Docs
31 |
32 | [Read the docs](https://github.com/epicweb-dev/epic-stack/blob/main/docs)
33 | (please 🙏).
34 |
35 | ## Support
36 |
37 | - 🆘 Join the
38 | [discussion on GitHub](https://github.com/epicweb-dev/epic-stack/discussions)
39 | and the [KCD Community on Discord](https://kcd.im/discord).
40 | - 💡 Create an
41 | [idea discussion](https://github.com/epicweb-dev/epic-stack/discussions/new?category=ideas)
42 | for suggestions.
43 | - 🐛 Open a [GitHub issue](https://github.com/epicweb-dev/epic-stack/issues) to
44 | report a bug.
45 |
46 | ## Branding
47 |
48 | Want to talk about the Epic Stack in a blog post or talk? Great! Here are some
49 | assets you can use in your material:
50 | [EpicWeb.dev/brand](https://epicweb.dev/brand)
51 |
52 | ## Thanks
53 |
54 | You rock 🪨
55 |
--------------------------------------------------------------------------------
/app/components/confetti.tsx:
--------------------------------------------------------------------------------
1 | import { Index as ConfettiShower } from 'confetti-react'
2 | import { ClientOnly } from 'remix-utils/client-only'
3 |
4 | export function Confetti({ id }: { id?: string | null }) {
5 | if (!id) return null
6 |
7 | return (
8 |
9 | {() => (
10 |
18 | )}
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/app/components/error-boundary.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | type ErrorResponse,
3 | isRouteErrorResponse,
4 | useParams,
5 | useRouteError,
6 | } from '@remix-run/react'
7 | import { getErrorMessage } from '#app/utils/misc.tsx'
8 |
9 | type StatusHandler = (info: {
10 | error: ErrorResponse
11 | params: Record
12 | }) => JSX.Element | null
13 |
14 | export function GeneralErrorBoundary({
15 | defaultStatusHandler = ({ error }) => (
16 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/app/utils/confetti.server.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from '@remix-run/node'
2 | import * as cookie from 'cookie'
3 | import { combineHeaders } from './misc.tsx'
4 |
5 | const cookieName = 'en_confetti'
6 |
7 | export function getConfetti(request: Request) {
8 | const cookieHeader = request.headers.get('cookie')
9 | const confettiId = cookieHeader
10 | ? cookie.parse(cookieHeader)[cookieName]
11 | : null
12 | return {
13 | confettiId,
14 | headers: confettiId ? createConfettiHeaders(null) : null,
15 | }
16 | }
17 |
18 | /**
19 | * This defaults the value to something reasonable if you want to show confetti.
20 | * If you want to clear the cookie, pass null and it will make a set-cookie
21 | * header that will delete the cookie
22 | *
23 | * @param value the value for the cookie in the set-cookie header
24 | * @returns Headers with a set-cookie header set to the value
25 | */
26 | export function createConfettiHeaders(
27 | value: string | null = String(Date.now()),
28 | ) {
29 | return new Headers({
30 | 'set-cookie': cookie.serialize(cookieName, value ? value : '', {
31 | path: '/',
32 | maxAge: value ? 60 : -1,
33 | }),
34 | })
35 | }
36 |
37 | export async function redirectWithConfetti(url: string, init?: ResponseInit) {
38 | return redirect(url, {
39 | ...init,
40 | headers: combineHeaders(init?.headers, await createConfettiHeaders()),
41 | })
42 | }
43 |
--------------------------------------------------------------------------------
/app/utils/connections.server.ts:
--------------------------------------------------------------------------------
1 | import { createCookieSessionStorage } from '@remix-run/node'
2 | import { type ProviderName } from './connections.tsx'
3 | import { GitHubProvider } from './providers/github.server.ts'
4 | import { type AuthProvider } from './providers/provider.ts'
5 | import { type Timings } from './timing.server.ts'
6 |
7 | export const connectionSessionStorage = createCookieSessionStorage({
8 | cookie: {
9 | name: 'en_connection',
10 | sameSite: 'lax',
11 | path: '/',
12 | httpOnly: true,
13 | maxAge: 60 * 10, // 10 minutes
14 | secrets: process.env.SESSION_SECRET.split(','),
15 | secure: process.env.NODE_ENV === 'production',
16 | },
17 | })
18 |
19 | export const providers: Record = {
20 | github: new GitHubProvider(),
21 | }
22 |
23 | export function handleMockAction(providerName: ProviderName, request: Request) {
24 | return providers[providerName].handleMockAction(request)
25 | }
26 |
27 | export function resolveConnectionData(
28 | providerName: ProviderName,
29 | providerId: string,
30 | options?: { timings?: Timings },
31 | ) {
32 | return providers[providerName].resolveConnectionData(providerId, options)
33 | }
34 |
--------------------------------------------------------------------------------
/app/utils/connections.tsx:
--------------------------------------------------------------------------------
1 | import { Form } from '@remix-run/react'
2 | import { z } from 'zod'
3 | import { Icon } from '#app/components/ui/icon.tsx'
4 | import { StatusButton } from '#app/components/ui/status-button.tsx'
5 | import { useIsPending } from './misc.tsx'
6 |
7 | export const GITHUB_PROVIDER_NAME = 'github'
8 | // to add another provider, set their name here and add it to the providerNames below
9 |
10 | export const providerNames = [GITHUB_PROVIDER_NAME] as const
11 | export const ProviderNameSchema = z.enum(providerNames)
12 | export type ProviderName = z.infer
13 |
14 | export const providerLabels: Record = {
15 | [GITHUB_PROVIDER_NAME]: 'GitHub',
16 | } as const
17 |
18 | export const providerIcons: Record = {
19 | [GITHUB_PROVIDER_NAME]: ,
20 | } as const
21 |
22 | export function ProviderConnectionForm({
23 | redirectTo,
24 | type,
25 | providerName,
26 | }: {
27 | redirectTo?: string | null
28 | type: 'Connect' | 'Login' | 'Signup'
29 | providerName: ProviderName
30 | }) {
31 | const label = providerLabels[providerName]
32 | const formAction = `/auth/${providerName}`
33 | const isPending = useIsPending({ formAction })
34 | return (
35 |
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/app/utils/csrf.server.ts:
--------------------------------------------------------------------------------
1 | import { createCookie } from '@remix-run/node'
2 | import { CSRF, CSRFError } from 'remix-utils/csrf/server'
3 |
4 | const cookie = createCookie('csrf', {
5 | path: '/',
6 | httpOnly: true,
7 | secure: process.env.NODE_ENV === 'production',
8 | sameSite: 'lax',
9 | secrets: process.env.SESSION_SECRET.split(','),
10 | })
11 |
12 | export const csrf = new CSRF({ cookie })
13 |
14 | export async function validateCSRF(formData: FormData, headers: Headers) {
15 | try {
16 | await csrf.validate(formData, headers)
17 | } catch (error) {
18 | if (error instanceof CSRFError) {
19 | throw new Response('Invalid CSRF token', { status: 403 })
20 | }
21 | throw error
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/app/utils/db.server.ts:
--------------------------------------------------------------------------------
1 | import { remember } from '@epic-web/remember'
2 | import { PrismaClient } from '@prisma/client'
3 | import chalk from 'chalk'
4 |
5 | export const prisma = remember('prisma', () => {
6 | // NOTE: if you change anything in this function you'll need to restart
7 | // the dev server to see your changes.
8 |
9 | // Feel free to change this log threshold to something that makes sense for you
10 | const logThreshold = 20
11 |
12 | const client = new PrismaClient({
13 | log: [
14 | { level: 'query', emit: 'event' },
15 | { level: 'error', emit: 'stdout' },
16 | { level: 'warn', emit: 'stdout' },
17 | ],
18 | })
19 | client.$on('query', async e => {
20 | if (e.duration < logThreshold) return
21 | const color =
22 | e.duration < logThreshold * 1.1
23 | ? 'green'
24 | : e.duration < logThreshold * 1.2
25 | ? 'blue'
26 | : e.duration < logThreshold * 1.3
27 | ? 'yellow'
28 | : e.duration < logThreshold * 1.4
29 | ? 'redBright'
30 | : 'red'
31 | const dur = chalk[color](`${e.duration}ms`)
32 | console.info(`prisma:query - ${dur} - ${e.query}`)
33 | })
34 | client.$connect()
35 | return client
36 | })
37 |
--------------------------------------------------------------------------------
/app/utils/env.server.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | const schema = z.object({
4 | NODE_ENV: z.enum(['production', 'development', 'test'] as const),
5 | DATABASE_PATH: z.string(),
6 | DATABASE_URL: z.string(),
7 | SESSION_SECRET: z.string(),
8 | INTERNAL_COMMAND_TOKEN: z.string(),
9 | HONEYPOT_SECRET: z.string(),
10 | CACHE_DATABASE_PATH: z.string(),
11 | // If you plan on using Sentry, uncomment this line
12 | // SENTRY_DSN: z.string(),
13 | // If you plan to use Resend, uncomment this line
14 | // RESEND_API_KEY: z.string(),
15 | // If you plan to use GitHub auth, remove the default:
16 | GITHUB_CLIENT_ID: z.string().default('MOCK_GITHUB_CLIENT_ID'),
17 | GITHUB_CLIENT_SECRET: z.string().default('MOCK_GITHUB_CLIENT_SECRET'),
18 | GITHUB_TOKEN: z.string().default('MOCK_GITHUB_TOKEN'),
19 | })
20 |
21 | declare global {
22 | namespace NodeJS {
23 | interface ProcessEnv extends z.infer {}
24 | }
25 | }
26 |
27 | export function init() {
28 | const parsed = schema.safeParse(process.env)
29 |
30 | if (parsed.success === false) {
31 | console.error(
32 | '❌ Invalid environment variables:',
33 | parsed.error.flatten().fieldErrors,
34 | )
35 |
36 | throw new Error('Invalid envirmonment variables')
37 | }
38 | }
39 |
40 | /**
41 | * This is used in both `entry.server.ts` and `root.tsx` to ensure that
42 | * the environment variables are set and globally available before the app is
43 | * started.
44 | *
45 | * NOTE: Do *not* add any environment variables in here that you do not wish to
46 | * be included in the client.
47 | * @returns all public ENV variables
48 | */
49 | export function getEnv() {
50 | return {
51 | MODE: process.env.NODE_ENV,
52 | SENTRY_DSN: process.env.SENTRY_DSN,
53 | }
54 | }
55 |
56 | type ENV = ReturnType
57 |
58 | declare global {
59 | var ENV: ENV
60 | interface Window {
61 | ENV: ENV
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/app/utils/honeypot.server.ts:
--------------------------------------------------------------------------------
1 | import { Honeypot, SpamError } from 'remix-utils/honeypot/server'
2 |
3 | export const honeypot = new Honeypot({
4 | validFromFieldName: process.env.TESTING ? null : undefined,
5 | encryptionSeed: process.env.HONEYPOT_SECRET,
6 | })
7 |
8 | export function checkHoneypot(formData: FormData) {
9 | try {
10 | honeypot.check(formData)
11 | } catch (error) {
12 | if (error instanceof SpamError) {
13 | throw new Response('Form not submitted properly', { status: 400 })
14 | }
15 | throw error
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/app/utils/litefs.server.ts:
--------------------------------------------------------------------------------
1 | // litefs-js should be used server-side only. It imports `fs` which results in Remix
2 | // including a big polyfill. So we put the import in a `.server.ts` file to avoid that
3 | // polyfill from being included. https://github.com/epicweb-dev/epic-stack/pull/331
4 | export * from 'litefs-js'
5 | export * from 'litefs-js/remix.js'
6 |
--------------------------------------------------------------------------------
/app/utils/misc.error-message.test.ts:
--------------------------------------------------------------------------------
1 | import { faker } from '@faker-js/faker'
2 | import { expect, test } from 'vitest'
3 | import { consoleError } from '#tests/setup/setup-test-env.ts'
4 | import { getErrorMessage } from './misc.tsx'
5 |
6 | test('Error object returns message', () => {
7 | const message = faker.lorem.words(2)
8 | expect(getErrorMessage(new Error(message))).toBe(message)
9 | })
10 |
11 | test('String returns itself', () => {
12 | const message = faker.lorem.words(2)
13 | expect(getErrorMessage(message)).toBe(message)
14 | })
15 |
16 | test('undefined falls back to Unknown', () => {
17 | consoleError.mockImplementation(() => {})
18 | expect(getErrorMessage(undefined)).toBe('Unknown Error')
19 | expect(consoleError).toHaveBeenCalledWith(
20 | 'Unable to get error message for error',
21 | undefined,
22 | )
23 | expect(consoleError).toHaveBeenCalledTimes(1)
24 | })
25 |
--------------------------------------------------------------------------------
/app/utils/monitoring.client.tsx:
--------------------------------------------------------------------------------
1 | import { useLocation, useMatches } from '@remix-run/react'
2 | import * as Sentry from '@sentry/remix'
3 | import { useEffect } from 'react'
4 |
5 | export function init() {
6 | Sentry.init({
7 | dsn: ENV.SENTRY_DSN,
8 | integrations: [
9 | new Sentry.BrowserTracing({
10 | routingInstrumentation: Sentry.remixRouterInstrumentation(
11 | useEffect,
12 | useLocation,
13 | useMatches,
14 | ),
15 | }),
16 | // Replay is only available in the client
17 | new Sentry.Replay(),
18 | ],
19 |
20 | // Set tracesSampleRate to 1.0 to capture 100%
21 | // of transactions for performance monitoring.
22 | // We recommend adjusting this value in production
23 | tracesSampleRate: 1.0,
24 |
25 | // Capture Replay for 10% of all sessions,
26 | // plus for 100% of sessions with an error
27 | replaysSessionSampleRate: 0.1,
28 | replaysOnErrorSampleRate: 1.0,
29 | })
30 | }
31 |
--------------------------------------------------------------------------------
/app/utils/monitoring.server.ts:
--------------------------------------------------------------------------------
1 | import * as Sentry from '@sentry/remix'
2 |
3 | export function init() {
4 | Sentry.init({
5 | dsn: ENV.SENTRY_DSN,
6 | tracesSampleRate: 1,
7 | // TODO: Make this work with Prisma
8 | // integrations: [new Sentry.Integrations.Prisma({ client: prisma })],
9 | })
10 | }
11 |
--------------------------------------------------------------------------------
/app/utils/nonce-provider.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | export const NonceContext = React.createContext('')
4 | export const NonceProvider = NonceContext.Provider
5 | export const useNonce = () => React.useContext(NonceContext)
6 |
--------------------------------------------------------------------------------
/app/utils/providers/provider.ts:
--------------------------------------------------------------------------------
1 | import { type Strategy } from 'remix-auth'
2 | import { type Timings } from '../timing.server.ts'
3 |
4 | // Define a user type for cleaner typing
5 | export type ProviderUser = {
6 | id: string
7 | email: string
8 | username?: string
9 | name?: string
10 | imageUrl?: string
11 | }
12 |
13 | export interface AuthProvider {
14 | getAuthStrategy(): Strategy
15 | handleMockAction(request: Request): Promise
16 | resolveConnectionData(
17 | providerId: string,
18 | options?: { timings?: Timings },
19 | ): Promise<{
20 | displayName: string
21 | link?: string | null
22 | }>
23 | }
24 |
--------------------------------------------------------------------------------
/app/utils/redirect-cookie.server.ts:
--------------------------------------------------------------------------------
1 | import * as cookie from 'cookie'
2 |
3 | const key = 'redirectTo'
4 | export const destroyRedirectToHeader = cookie.serialize(key, '', { maxAge: -1 })
5 |
6 | export function getRedirectCookieHeader(redirectTo?: string) {
7 | return redirectTo && redirectTo !== '/'
8 | ? cookie.serialize(key, redirectTo, { maxAge: 60 * 10 })
9 | : null
10 | }
11 |
12 | export function getRedirectCookieValue(request: Request) {
13 | const rawCookie = request.headers.get('cookie')
14 | const parsedCookies = rawCookie ? cookie.parse(rawCookie) : {}
15 | const redirectTo = parsedCookies[key]
16 | return redirectTo || null
17 | }
18 |
--------------------------------------------------------------------------------
/app/utils/request-info.ts:
--------------------------------------------------------------------------------
1 | import { useRouteLoaderData } from '@remix-run/react'
2 | import { type loader as rootLoader } from '#app/root.tsx'
3 | import { invariant } from './misc.tsx'
4 |
5 | /**
6 | * @returns the request info from the root loader
7 | */
8 | export function useRequestInfo() {
9 | const data = useRouteLoaderData('root')
10 | invariant(data?.requestInfo, 'No requestInfo found in root loader')
11 |
12 | return data.requestInfo
13 | }
14 |
--------------------------------------------------------------------------------
/app/utils/search.server.ts:
--------------------------------------------------------------------------------
1 | import { type GitHubFile } from './compile-mdx.server.ts'
2 | import { downloadDirList } from './github.server.ts'
3 | import { downloadMdxFilesCached } from './mdx.tsx'
4 |
5 | export const findDocsMatch = async (
6 | searchTerm: string,
7 | ): Promise => {
8 | const directories = await downloadDirList('content/docs')
9 | const filesPromises = directories.map(async directory => {
10 | const slug = directory.path.split('/').at(-1)
11 | if (!slug) return null
12 | return await downloadMdxFilesCached('docs', slug, {})
13 | })
14 |
15 | // Wait for all promises to resolve
16 | const files = await Promise.all(filesPromises)
17 |
18 | // Filter out directories that are null and then files that don't include the search term
19 | const matchedFiles = files.flatMap(
20 | dir =>
21 | dir?.files.filter(f =>
22 | f.content.toLowerCase().includes(searchTerm.toLowerCase()),
23 | ) || [],
24 | )
25 |
26 | console.log('===results===', matchedFiles.length)
27 | return matchedFiles // Return the flat array of matched GitHubFile objects
28 | }
29 |
--------------------------------------------------------------------------------
/app/utils/session.server.ts:
--------------------------------------------------------------------------------
1 | import { createCookieSessionStorage } from '@remix-run/node'
2 |
3 | export const authSessionStorage = createCookieSessionStorage({
4 | cookie: {
5 | name: 'en_session',
6 | sameSite: 'lax',
7 | path: '/',
8 | httpOnly: true,
9 | secrets: process.env.SESSION_SECRET.split(','),
10 | secure: process.env.NODE_ENV === 'production',
11 | },
12 | })
13 |
14 | // we have to do this because every time you commit the session you overwrite it
15 | // so we store the expiration time in the cookie and reset it every time we commit
16 | const originalCommitSession = authSessionStorage.commitSession
17 |
18 | Object.defineProperty(authSessionStorage, 'commitSession', {
19 | value: async function commitSession(
20 | ...args: Parameters
21 | ) {
22 | const [session, options] = args
23 | if (options?.expires) {
24 | session.set('expires', options.expires)
25 | }
26 | if (options?.maxAge) {
27 | session.set('expires', new Date(Date.now() + options.maxAge * 1000))
28 | }
29 | const expires = session.has('expires')
30 | ? new Date(session.get('expires'))
31 | : undefined
32 | const setCookieHeader = await originalCommitSession(session, {
33 | ...options,
34 | expires,
35 | })
36 | return setCookieHeader
37 | },
38 | })
39 |
--------------------------------------------------------------------------------
/app/utils/theme.server.ts:
--------------------------------------------------------------------------------
1 | import * as cookie from 'cookie'
2 |
3 | const cookieName = 'en_theme'
4 | export type Theme = 'light' | 'dark'
5 |
6 | export function getTheme(request: Request): Theme | null {
7 | const cookieHeader = request.headers.get('cookie')
8 | const parsed = cookieHeader ? cookie.parse(cookieHeader)[cookieName] : 'light'
9 | if (parsed === 'light' || parsed === 'dark') return parsed
10 | return null
11 | }
12 |
13 | export function setTheme(theme: Theme | 'system') {
14 | if (theme === 'system') {
15 | return cookie.serialize(cookieName, '', { path: '/', maxAge: -1 })
16 | } else {
17 | return cookie.serialize(cookieName, theme, { path: '/' })
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/utils/toast.server.ts:
--------------------------------------------------------------------------------
1 | import { createId as cuid } from '@paralleldrive/cuid2'
2 | import { createCookieSessionStorage, redirect } from '@remix-run/node'
3 | import { z } from 'zod'
4 | import { combineHeaders } from './misc.tsx'
5 |
6 | export const toastKey = 'toast'
7 |
8 | const TypeSchema = z.enum(['message', 'success', 'error'])
9 | const ToastSchema = z.object({
10 | description: z.string(),
11 | id: z.string().default(() => cuid()),
12 | title: z.string().optional(),
13 | type: TypeSchema.default('message'),
14 | })
15 |
16 | export type Toast = z.infer
17 | export type OptionalToast = Omit & {
18 | id?: string
19 | type?: z.infer
20 | }
21 |
22 | export const toastSessionStorage = createCookieSessionStorage({
23 | cookie: {
24 | name: 'en_toast',
25 | sameSite: 'lax',
26 | path: '/',
27 | httpOnly: true,
28 | secrets: process.env.SESSION_SECRET.split(','),
29 | secure: process.env.NODE_ENV === 'production',
30 | },
31 | })
32 |
33 | export async function redirectWithToast(
34 | url: string,
35 | toast: OptionalToast,
36 | init?: ResponseInit,
37 | ) {
38 | return redirect(url, {
39 | ...init,
40 | headers: combineHeaders(init?.headers, await createToastHeaders(toast)),
41 | })
42 | }
43 |
44 | export async function createToastHeaders(optionalToast: OptionalToast) {
45 | const session = await toastSessionStorage.getSession()
46 | const toast = ToastSchema.parse(optionalToast)
47 | session.flash(toastKey, toast)
48 | const cookie = await toastSessionStorage.commitSession(session)
49 | return new Headers({ 'set-cookie': cookie })
50 | }
51 |
52 | export async function getToast(request: Request) {
53 | const session = await toastSessionStorage.getSession(
54 | request.headers.get('cookie'),
55 | )
56 | const result = ToastSchema.safeParse(session.get(toastKey))
57 | const toast = result.success ? result.data : null
58 | return {
59 | toast,
60 | headers: toast
61 | ? new Headers({
62 | 'set-cookie': await toastSessionStorage.destroySession(session),
63 | })
64 | : null,
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/app/utils/totp.server.ts:
--------------------------------------------------------------------------------
1 | // @epic-web/totp should be used server-side only. It imports `Crypto` which results in Remix
2 | // including a big polyfill. So we put the import in a `.server.ts` file to avoid that
3 | export * from '@epic-web/totp'
4 |
--------------------------------------------------------------------------------
/app/utils/user-validation.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const UsernameSchema = z
4 | .string({ required_error: 'Username is required' })
5 | .min(3, { message: 'Username is too short' })
6 | .max(20, { message: 'Username is too long' })
7 | .regex(/^[a-zA-Z0-9_]+$/, {
8 | message: 'Username can only include letters, numbers, and underscores',
9 | })
10 | // users can type the username in any case, but we store it in lowercase
11 | .transform(value => value.toLowerCase())
12 |
13 | export const PasswordSchema = z
14 | .string({ required_error: 'Password is required' })
15 | .min(6, { message: 'Password is too short' })
16 | .max(100, { message: 'Password is too long' })
17 | export const NameSchema = z
18 | .string({ required_error: 'Name is required' })
19 | .min(3, { message: 'Name is too short' })
20 | .max(40, { message: 'Name is too long' })
21 | export const EmailSchema = z
22 | .string({ required_error: 'Email is required' })
23 | .email({ message: 'Email is invalid' })
24 | .min(3, { message: 'Email is too short' })
25 | .max(100, { message: 'Email is too long' })
26 | // users can type the email in any case, but we store it in lowercase
27 | .transform(value => value.toLowerCase())
28 |
29 | export const PasswordAndConfirmPasswordSchema = z
30 | .object({ password: PasswordSchema, confirmPassword: PasswordSchema })
31 | .superRefine(({ confirmPassword, password }, ctx) => {
32 | if (confirmPassword !== password) {
33 | ctx.addIssue({
34 | path: ['confirmPassword'],
35 | code: 'custom',
36 | message: 'The passwords must match',
37 | })
38 | }
39 | })
40 |
--------------------------------------------------------------------------------
/app/utils/user.ts:
--------------------------------------------------------------------------------
1 | import { type SerializeFrom } from '@remix-run/node'
2 | import { useRouteLoaderData } from '@remix-run/react'
3 | import { type loader as rootLoader } from '#app/root.tsx'
4 |
5 | function isUser(user: any): user is SerializeFrom['user'] {
6 | return user && typeof user === 'object' && typeof user.id === 'string'
7 | }
8 |
9 | export function useOptionalUser() {
10 | const data = useRouteLoaderData('root')
11 | if (!data || !isUser(data.user)) {
12 | return undefined
13 | }
14 | return data.user
15 | }
16 |
17 | export function useUser() {
18 | const maybeUser = useOptionalUser()
19 | if (!maybeUser) {
20 | throw new Error(
21 | 'No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead.',
22 | )
23 | }
24 | return maybeUser
25 | }
26 |
--------------------------------------------------------------------------------
/app/utils/verification.server.ts:
--------------------------------------------------------------------------------
1 | import { createCookieSessionStorage } from '@remix-run/node'
2 |
3 | export const verifySessionStorage = createCookieSessionStorage({
4 | cookie: {
5 | name: 'en_verification',
6 | sameSite: 'lax',
7 | path: '/',
8 | httpOnly: true,
9 | maxAge: 60 * 10, // 10 minutes
10 | secrets: process.env.SESSION_SECRET.split(','),
11 | secure: process.env.NODE_ENV === 'production',
12 | },
13 | })
14 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tailwind": {
6 | "config": "tailwind.config.ts",
7 | "css": "app/styles/global.css",
8 | "baseColor": "slate",
9 | "cssVariables": true
10 | },
11 | "aliases": {
12 | "components": "#app/components",
13 | "utils": "#app/utils/misc.tsx"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/content/decisions/README.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Epic Stack Decisions
3 | date: 2023-03-01
4 | description: Documenting the rationale behind choices made in the Epic Stack.
5 | ---
6 | ## Why on earth did they do that?
7 |
8 | This directory contains all the decisions we've made for this starter template and serves as a record for whenever we wonder why certain decisions were made.
9 |
10 | Documenting these decisions provides insight into the thought process and tradeoffs considered when building the Epic Stack. The documents explain the context around a decision, what choice was made, and what the implications are.
11 |
12 | Decisions in here are never final. As new information emerges or requirements change, previous decisions may need revisiting. However, these documents should serve as a good way for someone to come up to speed on why certain decisions were made originally.
13 |
14 | Having this decisions log makes it easier for contributors to understand the rationale behind the codebase, rather than just wondering "why did they do it that way?" It also helps ensure decisions are well-considered, since the process of writing out a decision document forces critical thinking.
15 |
16 | Over time, these decision records create a knowledge base full of architectural insights and lessons learned while building the Epic Stack. New contributors can reference past decisions to avoid repeating previous mistakes and build on proven solutions.
17 |
18 | So when you encounter an questionable choice in the codebase, consult the decision docs first before assuming there was no rationale behind it! Chances are, there is an entry explaining exactly why it was done that way.
--------------------------------------------------------------------------------
/content/decisions/content-security-policy/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 8
3 | title: Content Security Policy
4 | date: 2023-05-27
5 | status: accepted
6 | description: Configuring a strict CSP by default in the Epic Stack.
7 | categories:
8 | - security
9 | - csp
10 | - decisions
11 | meta:
12 | keywords:
13 | - csp
14 | - security
15 | ---
16 | Update: [022-report-only-csp.md](./022-report-only-csp.md)
17 |
18 | ## Context
19 |
20 | A Content Security Policy (CSP) allows a server to inform the browser about the
21 | sources from which it expects to load resources. This helps to prevent
22 | cross-site scripting (XSS) attacks by not allowing the browser to load resources
23 | from any other location than the ones specified in the CSP.
24 |
25 | CSPs that are overly strict can be a major pain to work with, especially when
26 | using third-party libraries. Still, for the most security, the CSP should be as
27 | strict as possible. Additional sources can be added to the CSP as needed.
28 |
29 | ## Decision
30 |
31 | We configure a tight CSP for the default application using
32 | [helmet](https://npm.im/helmet) which is a de-facto standard express middleware
33 | for configuring security headers.
34 |
35 | ## Consequences
36 |
37 | Applications using the Epic Stack will start with a safer default configuration
38 | for their CSP. It's pretty simple to add additional sources to the CSP as
39 | needed, but it could definitely be confusing for folks who are unaware of the
40 | CSP to load resources. Documentation will be needed to help people understand
41 | what to do when they get CSP errors.
42 |
--------------------------------------------------------------------------------
/content/decisions/csrf/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 32
3 | title: CSRF
4 | date: 2023-10-11
5 | status: accepted
6 | description: Adding CSRF protection to forms.
7 | categories:
8 | - security
9 | - decisions
10 | meta:
11 | keywords:
12 | - csrf
13 | - security
14 | ---
15 | ## Context
16 |
17 | You can learn all about Cross-Site Request Forgery from
18 | [EpicWeb.dev's forms workshop](https://forms.epicweb.dev/07). The TL;DR idea is
19 | that a malicious adversary can trick a user into making a request to your server
20 | that they did not intend to make. This can be used to make requests to your
21 | server that can do anything that the user can do.
22 |
23 | To defend against this attack, we need to ensure that the request is coming from
24 | a page that we control. We do this by adding a CSRF token to the page and
25 | checking that the token is present in the request. The token is generated by our
26 | own server and stored in an HTTP-only cookie. This means that it can't be
27 | accessed by third parties, but it will be sent with every request to our server.
28 | We also send that same token within the form submission and then check that the
29 | token in the form matches the token in the cookie.
30 |
31 | Once set up, this is a fairly straightforward thing to do and there are great
32 | tools to help us do it (`remix-utils` specifically).
33 |
34 | ## Decision
35 |
36 | We'll implement CSRF protection to all our authenticated forms.
37 |
38 | ## Consequences
39 |
40 | This is a tiny bit invasive to the code, but it doesn't add much complexity.
41 | It's certainly worth the added security.
42 |
--------------------------------------------------------------------------------
/content/decisions/email-code/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 13
3 | title: Email Verification Code
4 | date: 2023-06-05
5 | status: accepted
6 | description: Sending verification code in addition to magic link.
7 | categories:
8 | - authentication
9 | - decisions
10 | meta:
11 | keywords:
12 | - verification
13 | - onboarding
14 | - authentication
15 | ---
16 |
17 | ## Context
18 |
19 | When a new user registers, we need to collect their email address so we can send
20 | them a password reset link if they forget their password. Applications may also
21 | need the email for other reasons, but whatever the case may be, we need their
22 | email address, and to reduce spam and user error, we want to verify the email as
23 | well.
24 |
25 | Currently, the Epic Stack will send the email with a link which the user can
26 | then click and start the onboarding process. This works fine, but it often means
27 | the user is left with a previous dead-end tab open which is kind of annoying
28 | (especially if they are on mobile and the email client opens the link in a
29 | different browser).
30 |
31 | An alternative to this is to include a verification code in the email and have
32 | the user enter that code into the application. This is a little more work for
33 | the user, but it's not too bad and it means that the user can continue their
34 | work from the same tab they started in.
35 |
36 | This also has implications if people want to add email verification for
37 | sensitive operations like password resets. If a code system is in place, it
38 | becomes much easier to add that verification to the password reset process as
39 | well.
40 |
41 | ## Decision
42 |
43 | We will support both options. The email will include a code and a link, giving
44 | the user the option between the two so they can select the one that works best
45 | for them in the situation.
46 |
47 | ## Consequences
48 |
49 | This requires a bit more work, but will ultimately be a better UX and will pave
50 | the way for other features in the future.
51 |
--------------------------------------------------------------------------------
/content/decisions/email-service/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 2
3 | title: Email Service
4 | date: 2023-05-08
5 | status: superseded, resend-email
6 | description: Why the Epic Stack uses a transactional email service.
7 | categories:
8 | - email
9 | - decisions
10 | meta:
11 | keywords:
12 | - email
13 | - mailgun
14 | - transactional
15 | ---
16 |
17 | ## Context
18 |
19 | When you're building a web application, you almost always need to send emails
20 | for various reasons. Packages like `nodemailer` make it quite easy to send your
21 | own emails through your own mailserver or a third party's SMTP server as well.
22 |
23 | Unfortunately,
24 | [deliverability will suffer if you're not using a service](https://cfenollosa.com/blog/after-self-hosting-my-email-for-twenty-three-years-i-have-thrown-in-the-towel-the-oligopoly-has-won.html).
25 | The TL;DR is you either dedicate your company's complete resources to "play the
26 | game" of email deliverability, or you use a service that does. Otherwise, your
27 | emails won't reliably make it through spam filters (and in some cases it can
28 | just get deleted altogether).
29 |
30 | [The guiding principles](https://github.com/epicweb-dev/epic-stack/blob/main/docs/guiding-principles.md)
31 | discourage services and encourage quick setup.
32 |
33 | ## Decision
34 |
35 | We will use a service for sending email. If emails don't get delivered then it
36 | defeats the whole purpose of sending email.
37 |
38 | We selected [Mailgun](https://www.mailgun.com/) because it has a generous free
39 | tier and has proven itself in production. However, to help with quick setup, we
40 | will allow deploying to production without the Mailgun environment variables set
41 | and will instead log the email to the console so during the experimentation
42 | phase, developers can still read the emails that would have been sent.
43 |
44 | During local development, the Mailgun APIs are mocked and logged in the terminal
45 | as well as saved to the fixtures directory for tests to reference.
46 |
47 | ## Consequences
48 |
49 | Developers will need to either sign up for Mailgun or update the email code to
50 | use another service if they prefer. Emails will actually reach their
51 | destination.
52 |
--------------------------------------------------------------------------------
/content/decisions/honeypot/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 33
3 | title: Honeypot Fields
4 | date: 2023-10-11
5 | status: accepted
6 | description: Adding honeypots to public forms.
7 | categories:
8 | - honeypot
9 | - spam
10 | - decisions
11 | meta:
12 | keywords:
13 | - honeypot
14 | - spam
15 | ---
16 |
17 | ## Context
18 |
19 | You can learn all about Honeypot Fields from
20 | [EpicWeb.dev's forms workshop](https://forms.epicweb.dev/06). The TL;DR idea is
21 | spam bots go around the internet filling in forms all over the place in hopes of
22 | getting their spammy links on your site among other things. This causes extra
23 | load on your server and in some cases can cause you issues. For example, our
24 | onboarding process sends an email to the user. If a spam bot fills out the form
25 | with a random email address, we'll send an email to that address and cause
26 | confusion in the best case or get marked as spam in the worst case.
27 |
28 | Most of these spam bots are not very sophisticated and will fill in every field
29 | on the form (even if those fields are visually hidden). We can use this to our
30 | advantage by adding a field to the form that is visually hidden and then
31 | checking that it is empty when the form is submitted. If it is not empty, we
32 | know that the form was filled out by a spam bot and we can ignore it.
33 |
34 | There are great tools to help us accomplish this (`remix-utils` specifically).
35 |
36 | ## Decision
37 |
38 | We'll implement Honeypot Fields to all our public-facing forms. Authenticated
39 | forms won't need this because they're not accessible to spam bots anyway.
40 |
41 | ## Consequences
42 |
43 | This is a tiny bit invasive to the code, but it doesn't add much complexity.
44 | It's certainly worth the added benefits to our server (and email
45 | deliverability).
46 |
--------------------------------------------------------------------------------
/content/decisions/icons/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 20
3 | title: Icons
4 | date: 2023-06-28
5 | status: accepted
6 | description: Using SVG sprites for icons.
7 | categories:
8 | - icons
9 | - decisions
10 | meta:
11 | keywords:
12 | - icons
13 | - svg
14 | - sprites
15 | ---
16 | ## Context
17 |
18 | Icons are a critical part to every application. It helps users quickly identify
19 | different actions they can take and the meaning of different elements on the
20 | page. It's pretty well accepted that SVGs are the way to go with icons, but
21 | there are a few different options for how to go about doing this.
22 |
23 | Because the Epic Stack is using React, it may feel obvious to just use a
24 | component per icon and inline the SVG in the component. This is fine, but it's
25 | sub-optimal. I'm not going to spend time explaining why, because
26 | [this article does a great job of that](https://benadam.me/thoughts/react-svg-sprites/).
27 |
28 | SVG sprites are no less ergonomic than inline SVGs in React because in either
29 | case you need to do some sort of transformation of the SVG to make it useable in
30 | the application. If you inline SVGs, you have [SVGR](https://react-svgr.com/) to
31 | automate this process. So if we can automate the process of creating and
32 | consuming a sprite, we're in a fine place.
33 |
34 | And [rmx-cli](https://github.com/kiliman/rmx-cli) has support for automating the
35 | creation of an SVG sprite.
36 |
37 | One drawback to sprites is you don't typically install a library of icons and
38 | then use them like regular components. You do need to have a process for adding
39 | these to the sprite. And you wouldn't want to add every possible icon as there's
40 | no "tree-shaking" for sprites.
41 |
42 | ## Decision
43 |
44 | Setup the project to use SVG sprites with `rmx-cli`.
45 |
46 | ## Consequences
47 |
48 | We'll need to document the process of adding SVGs. It's still possible to simply
49 | install a library of icons and use them as components if you're ok with the
50 | trade-offs of that approach. But the default in the starter will be to use
51 | sprites.
52 |
--------------------------------------------------------------------------------
/content/decisions/monitoring/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 15
3 | title: Monitoring
4 | date: 2023-06-09
5 | status: accepted
6 | description: Using Sentry for monitoring and errors.
7 | categories:
8 | - monitoring
9 | - sentry
10 | - decisions
11 | meta:
12 | keywords:
13 | - monitoring
14 | - sentry
15 | ---
16 |
17 | ## Context
18 |
19 | Unless you want to be watching your metrics and logs 24/7 you probably want to
20 | be notified when users experience errors in your application. There are great
21 | tools for monitoring your application. I've used Sentry for years and it's
22 | great.
23 |
24 | One of the guiding principles of the project is to avoid services. The nature of
25 | application monitoring requires that the monitor not be part of the application.
26 | So, we necessarily need to use a service for monitoring.
27 |
28 | One nice thing about Sentry is it is open source so we can run it ourselves if
29 | we like. However, that may be more work than we want to take on at first.
30 |
31 | ## Decision
32 |
33 | We'll set up the Epic Stack to use Sentry and document how you could get it
34 | running yourself if you prefer to self-host it.
35 |
36 | We'll also ensure that we defer the setup requirement to later so you can still
37 | get started with the Epic Stack without monitoring in place which is very useful
38 | for experiments and makes it easier to remove or adapt to a different solution
39 | if you so desire.
40 |
41 | ## Consequences
42 |
43 | We tie the Epic Stack to Sentry a bit, but I think that's a solid trade-off for
44 | the benefit of production error monitoring that Sentry provides. People who need
45 | the scale where Sentry starts to cost money (https://sentry.io/pricing/) will
46 | probably be making money at that point and will be grateful for the monitoring.
47 |
--------------------------------------------------------------------------------
/content/decisions/native-esm/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 6
3 | title: Native ESM
4 | date: 2023-05-18
5 | status: accepted
6 | description: Why the Epic Stack uses ESM as the default module system.
7 | categories:
8 | - esm
9 | - decisions
10 | meta:
11 | keywords:
12 | - esm
13 | - cjs
14 | - modules
15 | ---
16 |
17 | ## Context
18 |
19 | Oh boy, where do I start? The history of JavaScript modules is long and
20 | complicated. I discuss this a bit in my talk
21 | [More than you want to know about ES6 Modules](https://kentcdodds.com/talks/more-than-you-want-to-know-about-es-6-modules).
22 | Many modern packages on npm are now publishing esm-only versions of their
23 | packages. This is fine, but it does mean that using them from a CommonJS module
24 | system requires dynamic imports which is limiting.
25 |
26 | In Remix v2, ESM will be the default behavior. Everywhere you look, ESM is
27 | becoming more and more the standard module option. CommonJS modules aren't going
28 | anywhere, but it's a good idea to stay on top of the latest.
29 |
30 | Sadly, this is a bit of a "who moved my cheese" situation. Developers who are
31 | familiar with CommonJS modules will be annoyed by things they were used to doing
32 | in CJS that they can't do the same way in ESM. The biggest is dynamic (and
33 | synchronous) requires. Another is the way that module resolution changes. There
34 | are some packages which aren't quite prepared for ESM and therefore you end up
35 | having to import their exports directly from the files (like radix for example).
36 | This is hopefully a temporary problem.
37 |
38 | ## Decision
39 |
40 | We're adopting ESM as the default module system for the Epic Stack.
41 |
42 | ## Consequences
43 |
44 | Experienced developers will hit a couple bumps along the way as they change
45 | their mental model for modules. But it's time to do this.
46 |
47 | Some tools aren't very ergonomic with ESM. This will hopefully improve over
48 | time.
49 |
--------------------------------------------------------------------------------
/content/decisions/path-aliases/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 26
3 | title: Path Aliases
4 | date: 2023-08-14
5 | status: superseded, imports
6 | description: Removing path aliases from TSConfig.
7 | categories:
8 | - typescript
9 | - decisions
10 | meta:
11 | keywords:
12 | - imports
13 | - aliases
14 | ---
15 | ## Context
16 |
17 | It's pretty common to configure TypeScript to have path aliases for imports.
18 | This allows you to avoid relative imports and makes it easier to move files
19 | around without having to update imports.
20 |
21 | When the Epic Stack started, we used path imports that were similar to those in
22 | the rest of the Remix ecosystem: `#` referenced the `app/` directory. We added
23 | `tests/` to make it easier to import test utils.
24 |
25 | However, we've found that this is confusing for new developers. It's not clear
26 | what `#` means, and seeing `import { thing } from 'tests/thing'` is confusing. I
27 | floated the idea of adding another alias for `@/` to be the app directory and or
28 | possibly just moving the `#` to the root and having that be the only alias. But
29 | at the end of the day, we're using TypeScript which will prevent us from making
30 | mistakes and modern editors will automatically handle imports for you anyway.
31 |
32 | At first it may feel like a pain, but less tooling magic is better and editors
33 | can really help reduce the pain. Additionally, we have ESLint configured to sort
34 | imports for us so we don't have to worry about that either. Just let the editor
35 | update the imports and let ESLint sort them.
36 |
37 | ## Decision
38 |
39 | Remove the path aliases from the `tsconfig`.
40 |
41 | ## Consequences
42 |
43 | This requires updating all the imports that utilized the path aliases to use
44 | relative imports.
45 |
--------------------------------------------------------------------------------
/content/decisions/region-selection/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 9
3 | title: Region Selection
4 | date: 2023-06-02
5 | status: accepted
6 | description: How region selection is handled during Epic Stack setup.
7 | categories:
8 | - regions
9 | - decisions
10 | meta:
11 | keywords:
12 | - regions
13 | - flyio
14 | ---
15 |
16 | ## Context
17 |
18 | Fly supports running your app in
19 | [34 regions](https://fly.io/docs/reference/regions/) all over the world. The
20 | Epic Stack is set up to allow you to run in as many of these regions as you
21 | like, but for cost reasons, it's best to start out with a single region until
22 | your app needs that level of scale.
23 |
24 | Region selection has an important impact on the performance of your app. When
25 | you're choosing a single region, you're choosing who your app is going to be
26 | slower for. So you really should choose the region that's closest to the most
27 | critical/closest users.
28 |
29 | Unfortunately, there's no way for us to know this for every app. We can't just
30 | select a region for you. And we also can't just select the region that's closest
31 | to you. We need you to actually think about and make this decision.
32 |
33 | ## Decision
34 |
35 | Ask which region the app should be deployed to during setup.
36 |
37 | ## Consequences
38 |
39 | Forces the developer to make a choice (goes against the "Minimize Setup
40 | Friction" guiding principle). However, we can make it slightly better by
41 | defaulting to the region that's closest to the developer.
42 |
--------------------------------------------------------------------------------
/content/decisions/remix-auth/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 29
3 | title: Remix Auth
4 | date: 2023-08-14
5 | status: accepted
6 | description: Handling auth instead of relying on remix-auth-form.
7 | categories:
8 | - authentication
9 | - decisions
10 | meta:
11 | keywords:
12 | - authentication
13 | - remix-auth
14 | ---
15 | ## Context
16 |
17 | At the start of Epic Stack, we were using
18 | [remix-auth-form](https://github.com/sergiodxa/remix-auth-form) for our
19 | username/password auth solution. This worked fine, but it really didn't give us
20 | any value over handling the auth song-and-dance ourselves.
21 |
22 | ## Decision
23 |
24 | Instead of relying on remix-auth for handling authenticating the user's login
25 | form submission, we'll manage it ourselves.
26 |
27 | ## Consequences
28 |
29 | This mostly allows us to remove some code. However, we're going to be keeping
30 | remix auth around for GitHub Auth
31 |
--------------------------------------------------------------------------------
/content/decisions/report-only-csp/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 22
3 | title: Report-only CSP
4 | date: 2023-07-14
5 | status: accepted
6 | description: Making the CSP report-only by default.
7 | categories:
8 | - csp
9 | - security
10 | - decisions
11 | meta:
12 | keywords:
13 | - csp
14 | - security
15 | ---
16 | ## Context
17 |
18 | The Epic Stack uses a strict
19 | [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP).
20 | All the reasons for this explained in
21 | [the decision document](./008-content-security-policy.md) still apply. However,
22 | As people adapt the Epic Stack to their own needs, they may easily forget to add
23 | important sources to the CSP. This can lead to a frustrating experience for new
24 | users of the Epic Stack.
25 |
26 | There's an option for CSPs called `report-only` which allows the browser to
27 | report CSP violations without actually blocking the resource. This turns the CSP
28 | into an opt-in which follows our [guiding principle](#app/guiding-principles.md)
29 | of "Minimize Setup Friction" (similar to deferring setup of third-party services
30 | until they're actually needed).
31 |
32 | ## Decision
33 |
34 | Enable report-only on the CSP by default.
35 |
36 | ## Consequences
37 |
38 | New users of the Epic Stack won't be blocked by the CSP by default. But this
39 | also means they won't be as safe by default. We'll need to make sure enforcing
40 | the CSP is documented well.
41 |
--------------------------------------------------------------------------------
/content/decisions/resend-email/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 17
3 | title: Migrating to Resend
4 | date: 2023-06-20
5 | status: accepted
6 | description: Switching to Resend as the email provider.
7 | categories:
8 | - email
9 | - resend
10 | - decisions
11 | meta:
12 | keywords:
13 | - resend
14 | - email
15 | ---
16 | ## Context
17 |
18 | Mailgun changed their pricing model to make it more difficult to understand what
19 | is available within the free tier which motivated us to re-evaluate our
20 | selection here. While mailgun is still a fine service,
21 | [Resend](https://resend.com/) has caught the attention of several users of the
22 | Epic Stack. It has a generous (and obvious) free tier of 3k emails a month. They
23 | check all the boxes regarding table-stakes features you'd expect from an email
24 | service. On top of those things, the UI is simple and easy to use. It's also a
25 | lot cheaper than Mailgun.
26 |
27 | ## Decision
28 |
29 | We'll migrate to Resend. As a part of this migration, we're going to avoid
30 | coupling ourselves too closely to it to make it easier to switch to another
31 | provider if you so desire. So we'll be using the REST API instead of the SDK.
32 |
33 | ## Consequences
34 |
35 | Code changes are relatively minimal. Only the `app/utils/email.server.ts` util
36 | and the mock for it need to be changed. Then we also need to update
37 | documentation to use the Resend API key instead of the mailgun sending domain,
38 | etc.
39 |
--------------------------------------------------------------------------------
/content/decisions/sitemaps/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 11
3 | title: Sitemaps
4 | date: 2023-06-05
5 | status: accepted
6 | description: Handling sitemaps as an opt-in example instead of built-in.
7 | categories:
8 | - seo
9 | - sitemaps
10 | - decisions
11 | meta:
12 | keywords:
13 | - sitemaps
14 | - seo
15 | ---
16 | ## Context
17 |
18 | [Sitemaps](https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview)
19 | are useful to help website crawlers (like search engines) find all the content
20 | on your website. Most of the time they aren't necessary if you're linking
21 | between pages well. However, for large websites with lots of content that are
22 | highly search engine sensitive, they can be useful.
23 |
24 | It's normally not a big deal to get them wrong if you don't care about it, but
25 | if you really don't care about it, having the code for it can get in the way and
26 | it's kind of annoying.
27 |
28 | ## Decision
29 |
30 | Instead of building a sitemap into the template, we'll use
31 | [an example](#app/examples.md) people can reference to add a sitemap to their
32 | Epic Stack sites if they like.
33 |
34 | ## Consequences
35 |
36 | This turns sitemaps into an opt-in for developers using the Epic Stack. Most
37 | people using the Epic Stack probably don't need a sitemap, and those who do will
38 | only need a few minutes of following the example to get it working.
39 |
--------------------------------------------------------------------------------
/content/decisions/template/index.mdx:
--------------------------------------------------------------------------------
1 | # Title
2 |
3 | Date: YYYY-MM-DD
4 |
5 | Status: proposed | rejected | accepted | deprecated | … | superseded by
6 | [0005](0005-example.md)
7 |
8 | ## Context
9 |
10 | ## Decision
11 |
12 | ## Consequences
13 |
--------------------------------------------------------------------------------
/content/decisions/toasts/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 27
3 | title: Toasts
4 | date: 2023-08-14
5 | status: accepted
6 | description: Switching to react-hot-toast for toast notifications.
7 | categories:
8 | - ui
9 | - notifications
10 | - decisions
11 | meta:
12 | keywords:
13 | - toasts
14 | - notifications
15 | ---
16 | ## Context
17 |
18 | In the Epic Stack we used the Shadcn toast implementation. This worked ok, but
19 | it did require a lot of custom code for ourselves and did a poor job of managing
20 | multiple toast messages.
21 |
22 | We also had a shared `flash` session implementation for both toasts and
23 | confetti. This was overly complex.
24 |
25 | There's another library
26 | [someone told me about](https://twitter.com/ayushverma1194/status/1674848096155467788)
27 | that is a better fit. It's simpler and has an API sufficient to our use cases.
28 |
29 | It's also sufficiently customizable from a design perspective as well. And it's
30 | actively developed.
31 |
32 | ## Decision
33 |
34 | Remove our own toast implementation and use the library instead.
35 |
36 | Also separate the toast and confetti session implementations. Toasts can
37 | continue to use a regular session, but confetti will be a much simpler cookie.
38 |
39 | ## Consequences
40 |
41 | This will limit the level of customizability because we're now relying on a
42 | library for managing toast messages, however it also reduces the maintenance
43 | burden for users of the Epic Stack.
44 |
45 | This will also simplify the confetti implementation.
46 |
--------------------------------------------------------------------------------
/content/decisions/typescript-only/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | decisionNumber: 1
3 | title: TypeScript Only
4 | date: 2023-05-08
5 | status: accepted
6 | description: Why the Epic Stack requires TypeScript.
7 | categories:
8 | - typescript
9 | - decisions
10 | meta:
11 | keywords:
12 | - typescript
13 | - javascript
14 | ---
15 |
16 | ## Context
17 |
18 | The `create-remix` CLI allows users to select whether they want to use
19 | JavaScript instead of TypeScript. This will auto-convert everything to
20 | JavaScript.
21 |
22 | There is (currently) no way to control this behavior.
23 |
24 | Teams and individuals building modern web applications have many great reasons
25 | to build them with TypeScript.
26 |
27 | One of the challenges with TypeScript is getting it configured properly. This is
28 | not an issue with a stack which starts you off on the right foot without needing
29 | to configure anything.
30 |
31 | Another challenge with TypeScript is handling dependencies that are not written
32 | in TypeScript. This is increasingly becoming less of an issue with more and more
33 | dependencies being written in TypeScript.
34 |
35 | ## Decision
36 |
37 | We strongly advise the use of TypeScript even for simple projects and those
38 | worked on by single developers. So instead of working on making this project
39 | work with the JavaScript option of the `create-remix` CLI, we've decided to
40 | throw an error informing the user to try again and select the TypeScript option.
41 |
42 | We've also made the example script in the `README.md` provide a selected option
43 | of `--typescript` so folks shouldn't even be asked unless they leave off that
44 | flag in which case our error will be thrown.
45 |
46 | ## Consequences
47 |
48 | This makes the initial experience not great for folks using JavaScript.
49 | Hopefully the Remix CLI will eventually allow us to have more control over
50 | whether that question is asked.
51 |
52 | This also may anger some folks who really don't like TypeScript. For those
53 | folks, feel free to fork the starter.
54 |
--------------------------------------------------------------------------------
/content/docs/apis/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: APIs
3 | date: 2023-11-02
4 | description: How the Epic Stack enables building APIs with resource routes and full stack components.
5 | categories:
6 | - api
7 | meta:
8 | keywords:
9 | - api
10 | - rest
11 | - graphql
12 | - endpoints
13 | - resource-routes
14 | ---
15 |
16 | Remix routes have the ability to handle both backend code and UI code in the same file. Remix `loader`s and `action`s are backend code that's tightly coupled to the UI code for that route.
17 |
18 | Additionally, you can define routes that don't have any UI at all. These are called [resource routes](https://remix.run/docs/en/main/guides/resource-routes). This allows you to create REST endpoints or a GraphQL endpoint to make your app data and logic consumable by third parties or additional clients (like a mobile app).
19 |
20 | You can also use this to generate PDFs, images, stream multi-media and more.
21 |
22 | The Epic Stack has a few resource routes in place for managing images, the cache, and even has a few ["full stack components"](https://www.epicweb.dev/full-stack-components) for components that manage the connection with their associated backend code. [Watch the talk](https://www.youtube.com/watch?v=30HAT5Quvgk&list=PLV5CVI1eNcJgNqzNwcs4UKrlJdhfDjshf).
23 |
24 | So, yes, you can absolutely use the Epic Stack to build APIs for consumption by third party clients.
--------------------------------------------------------------------------------
/content/docs/categories.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | 'Backend Development': [
3 | 'apis.md',
4 | 'database.md',
5 | 'deployment.md',
6 | 'server-timing.md',
7 | 'email.md',
8 | 'redirects.md',
9 | ],
10 | 'Frontend Development': [
11 | 'client-hints.md',
12 | 'fonts.md',
13 | 'icons.md',
14 | 'getting-started.md',
15 | 'seo.md',
16 | 'toast.md',
17 | ],
18 | 'Security & Authentication': [
19 | 'authentication.md',
20 | 'secrets.md',
21 | 'security.md',
22 | 'permissions.md',
23 | ],
24 | 'Infrastructure & Performance': [
25 | 'caching.md',
26 | 'memory.md',
27 | 'monitoring.md',
28 | 'routing.md',
29 | 'timezone.md',
30 | ],
31 | 'Best Practices': [
32 | 'examples.md',
33 | 'features.md',
34 | 'guiding-principles.md',
35 | 'managing-updates.md',
36 | 'testing.md',
37 | 'troubleshooting.md',
38 | ],
39 | Community: ['community.md'],
40 | }
41 |
--------------------------------------------------------------------------------
/content/docs/community/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Epic Stack Community
3 | date: 2023-11-02
4 | description: Learning resources and tools built by the Epic Stack community.
5 | categories:
6 | - community
7 | meta:
8 | keywords:
9 | - community
10 | - videos
11 | - examples
12 | - libraries
13 | ---
14 | Here you can find useful learning resources and tools built and maintained by
15 | the community, such as libraries, examples, articles, and videos.
16 |
17 | ## Learning resources
18 |
19 | The primary learning resources for the Epic Stack is
20 | [EpicWeb.dev](https://www.epicweb.dev), [EpicReact.dev](https://epicreact.dev),
21 | and [TestingJavaScript.com](https://testingjavascript.com). On these you will
22 | find free and paid premium content that will help you build epic web
23 | applications (with or without the Epic Stack).
24 |
25 | The community has put together some additional learning resources that you may
26 | enjoy!
27 |
28 | ### Videos
29 |
30 | - **Dark Mode Toggling using Client-preference cookies** by
31 | [@rajeshdavidbabu](https://github.com/rajeshdavidbabu) - Youtube
32 | [link](https://www.youtube.com/watch?v=UND-kib_iw4)
--------------------------------------------------------------------------------
/content/docs/email/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Email Setup
3 | date: 2023-11-02
4 | description: How to setup transactional email with Resend in the Epic Stack.
5 | categories:
6 | - email
7 | meta:
8 | keywords:
9 | - email
10 | - resend
11 | - transactional
12 | ---
13 | This document describes how to get [Resend](https://resend.com) (the Epic Stack
14 | email provider) setup.
15 |
16 | > **NOTE**: this is an optional step. During development the emails will be
17 | > logged to the terminal and in production if you haven't set the proper
18 | > environment variables yet you will get a warning until you set the environment
19 | > variables.
20 |
21 | Create [an API Key](https://resend.com/api-keys) and set `RESEND_API_KEY` in
22 | both prod and staging:
23 |
24 | ```sh
25 | fly secrets set RESEND_API_KEY="re_blAh_blaHBlaHblahBLAhBlAh" --app [YOUR_APP_NAME]
26 | fly secrets set RESEND_API_KEY="re_blAh_blaHBlaHblahBLAhBlAh" --app [YOUR_APP_NAME]-staging
27 | ```
28 |
29 | Setup a [custom sending domain](https://resend.com/domains) and then make sure
30 | to update the `from` email address in `app/utils/email.server.ts` and the
31 | `expect(email.from).toBe` in `tests/e2e/onboarding.test.ts` to the one you want
32 | your emails to come from.
33 |
--------------------------------------------------------------------------------
/content/docs/fonts/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Fonts
3 | date: 2023-11-02
4 | description: How to optimize custom web fonts in the Epic Stack.
5 | categories:
6 | - fonts
7 | - performance
8 | meta:
9 | keywords:
10 | - fonts
11 | - fontpie
12 | - metrics
13 | - layout-shift
14 | ---
15 | ## Font Metric Overrides
16 |
17 | When using custom fonts, your site elements may stretch or shrink to accommodate
18 | the font. This is because the browser doesn't know the dimensions of the font
19 | you're using until it arrives, which introduces Cumulative Layout Shift and
20 | impact its web vitals.
21 |
22 | In Epic Stack, we fixed this by introducing
23 | [Font Metric Overrides](https://github.com/epicweb-dev/epic-stack/pull/128/files).
24 |
25 | ### Custom Fonts Metric Overrides
26 |
27 | Adding metric overrides for your custom fonts is a manual process. Add a font
28 | family fallback name to your `tailwind.config.js` file:
29 |
30 | ```js
31 | //tailwind.config.js
32 | ...
33 | fontFamily: {
34 | yourFont: ['YourFont', 'YourFont Fallback'],
35 | }
36 | ```
37 |
38 | We'll use the [fontpie](https://www.npmjs.com/package/fontpie) utility to
39 | generate the overrides. For each of your fonts, write the following in your
40 | terminal:
41 |
42 | ```bash
43 | npx fontpie ./local/font/location.woff2 -w font-weight -s normal/italic -n YourFont
44 | ```
45 |
46 | ### Example
47 |
48 | ```sh
49 | npx fontpie ./public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200.woff2 -w 200 -s normal -n NunitoSans
50 | ```
51 |
52 | Use fontpie for every custom font used (including variants) and add the metric
53 | overrides to `font.css`.
54 |
55 | ```css
56 | @font-face {
57 | font-family: 'NunitoSans Fallback';
58 | font-style: normal;
59 | font-weight: 200;
60 | src: local('Arial');
61 | ascent-override: 103.02%;
62 | descent-override: 35.97%;
63 | line-gap-override: 0%;
64 | size-adjust: 98.13%;
65 | }
66 | ```
67 |
68 | _Ensure the original font has the `font-display: swap` property or the fallback
69 | wouldn't work!_
70 |
71 | That's it! You can now use your custom font without worrying about Cumulative
72 | Layout Shift!
73 |
--------------------------------------------------------------------------------
/content/docs/getting-started/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Getting Started with Epic Stack
3 | date: 2023-11-02
4 | description: How to generate a new Epic Stack project and run it locally.
5 | categories:
6 | - getting-started
7 | meta:
8 | keywords:
9 | - getting-started
10 | - initialize
11 | - setup
12 | ---
13 | The Epic Stack is a [Remix Stack](https://remix.run/stacks). To start your Epic
14 | Stack, run the following [`npx`](https://docs.npmjs.com/cli/v9/commands/npx)
15 | command:
16 |
17 | ```sh
18 | npx create-epic-app@latest
19 | ```
20 |
21 | This will prompt you for a project name (the name of the directory to put your
22 | project). Once you've selected that, the CLI will start the setup process.
23 |
24 | Once the setup is complete, go ahead and `cd` into the new project directory and
25 | run `npm run dev` to get the app started.
26 |
27 | Check the project README.md for instructions on getting the app deployed. You'll
28 | want to get this done early in the process to make sure you're all set up
29 | properly.
30 |
31 | If you'd like to skip some of the setup steps, you can set the following
32 | environment variables when you run the script:
33 |
34 | - `SKIP_SETUP` - skips running `npm run setup`
35 | - `SKIP_FORMAT` - skips running `npm run format`
36 | - `SKIP_DEPLOYMENT` - skips deployment setup
37 |
38 | So, if you enabled all of these it would be:
39 |
40 | ```sh
41 | SKIP_SETUP=true SKIP_FORMAT=true SKIP_DEPLOYMENT=true npx create-epic-app@latest
42 | ```
43 |
44 | Or, on windows:
45 |
46 | ```
47 | set SKIP_SETUP=true && set SKIP_FORMAT=true && set SKIP_DEPLOYMENT=true && npx create-epic-app@latest
48 | ```
49 |
50 | ## Development
51 |
52 | - Initial setup:
53 |
54 | ```sh
55 | npm run setup
56 | ```
57 |
58 | - Start dev server:
59 |
60 | ```sh
61 | npm run dev
62 | ```
63 |
64 | This starts your app in development mode, rebuilding assets on file changes.
65 |
66 | The database seed script creates a new user with some data you can use to get
67 | started:
68 |
69 | - Username: `kody`
70 | - Password: `kodylovesyou`
71 |
--------------------------------------------------------------------------------
/content/docs/guiding-principles/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Guiding Principles
3 | date: 2023-11-02
4 | description: The principles that guide decision making for the Epic Stack.
5 | categories:
6 | - principles
7 | meta:
8 | keywords:
9 | - principles
10 | - opinions
11 | - guidelines
12 | ---
13 | Decisions about the Epic Stack should be guided by the following guiding
14 | principles:
15 |
16 | - **Limit Services:** If we can reasonably build, deploy, maintain it ourselves,
17 | do it. Additionally, if we can reasonably run it within our app instance, do
18 | it. This saves on cost and reduces complexity.
19 | - **Include Only Most Common Use Cases:** As a project generator, it is expected
20 | that some code will necessarily be deleted, but implementing support for every
21 | possible type of feature is literally impossible. _The starter app is not
22 | docs_, so to demonstrate a feature or give an example, put that in the docs
23 | instead of in the starter app.
24 | - **Minimize Setup Friction:** Try to keep the amount of time it takes to get an
25 | app to production as small as possible. If a service is necessary, see if we
26 | can defer signup for that service until its services are actually required.
27 | Additionally, while the target audience for this stack is apps that need scale
28 | you have to pay for, we try to fit within the free tier of any services used
29 | during the exploration phase.
30 | - **Optimize for Adaptability:** While we feel great about our opinions,
31 | ever-changing product requirements sometimes necessitate swapping trade-offs.
32 | So while we try to keep things simple, we want to ensure teams using the Epic
33 | Stack are able to adapt by switching between third party services to
34 | custom-built services and vice-versa.
35 | - **Only one way:** Avoid providing more than one way to do the same thing. This
36 | applies to both the pre-configured code and the documentation.
37 | - **Offline Development:** We want to enable offline development as much as
38 | possible. Naturally we need to use third party services for some things (like
39 | email), but for those we'll strive to provide a way to mock them out for local
40 | development.
41 |
--------------------------------------------------------------------------------
/content/docs/landing/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Epic Stack Documentation
3 | date: 2023-11-02
4 | description: The home page for documentation of the Epic Stack.
5 | categories:
6 | - home
7 | meta:
8 | keywords:
9 | - home
10 | - overview
11 | - introduction
12 | ---
13 | The goal of The Epic Stack is to provide solid opinions for teams to hit the
14 | ground running on their web applications.
15 |
16 | We recommend you watch Kent's introduction to the Epic Stack to get an
17 | understanding of the "why" behind the Stack:
18 |
19 | [](https://www.epicweb.dev/talks/the-epic-stack)
20 |
21 | More of a reader? Read [the announcement post](https://epicweb.dev/epic-stack)
22 | or
23 | [an AI generated summary of the video](https://www.summarize.tech/www.youtube.com/watch?v=yMK5SVRASxM).
24 |
25 | This stack is still under active development. Documentation will rapidly improve
26 | in the coming weeks. Stay tuned!
27 |
28 | # Top Pages
29 |
30 | - [Getting Started](/topic/getting-started) - Instructions for how to get started
31 | with the Epic Stack.
32 | - [Features](/topic/features) - List of features the Epic Stack provides out of
33 | the box.
34 | - [Deployment](/topic/deployment) - If you skip the deployment step when starting
35 | your app, these are the manual steps you can follow to get things up and
36 | running.
37 | - [Decisions](/topic/decisions/README.md) - The reasoning behind various decisions
38 | made for the Epic Stack. A good historical record.
39 | - [Guiding Principles](/topic/guiding-principles) - The guiding principles behind
40 | the Epic Stack.
41 | - [Examples](/topic/examples) - Examples of the Epic Stack with various tools.
42 | Most new feature requests people have for the Epic Stack start as examples
43 | before being integrated into the framework.
44 | - [Managing Updates](/topic/managing-updates) - How to manage updates to the Epic
45 | Stack for both the generated stack code as well as npm dependencies.
46 |
--------------------------------------------------------------------------------
/content/docs/memory/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Memory Management
3 | date: 2023-11-02
4 | description: Configuring memory allocation and swap files in the Epic Stack.
5 | categories:
6 | - performance
7 | - memory
8 | meta:
9 | keywords:
10 | - memory
11 | - swap
12 | - flyio
13 | ---
14 | Epic Stack apps start with a single instance with 256MB of memory. This is a
15 | pretty small amount of memory, but it's enough to get started with. To help
16 | avoid memory pressure even at that scale, we allocate a 512MB swap file. Learn
17 | more about this decision in
18 | [the memory swap decision document](decisions/010-memory-swap.md).
19 |
20 | To modify or increase the swap file, check `other/setup-swap.js`. This file is
21 | executed before running our app within the `litefs.yml` config.
22 |
23 | > **NOTE**: PRs welcome to document how to determine the effectiveness of the
24 | > swap file for your app.
25 |
26 | To increase the memory allocated to your vm, use the
27 | [`fly scale`](https://fly.io/docs/flyctl/scale-memory/) command. You can
28 | [learn more about memory sizing in the Fly docs](https://fly.io/docs/machines/guides-examples/machine-sizing).
29 |
--------------------------------------------------------------------------------
/content/docs/monitoring/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Monitoring with Sentry
3 | date: 2023-11-02
4 | description: Setting up Sentry for monitoring in the Epic Stack.
5 | categories:
6 | - monitoring
7 | - sentry
8 | meta:
9 | keywords:
10 | - sentry
11 | - monitoring
12 | - errors
13 | ---
14 | This document describes how to get [Sentry](https://sentry.io/) (the Epic
15 | application monitoring provider) set up for error, performance, and replay
16 | monitoring.
17 |
18 | > **NOTE**: this is an optional step and only needed if you want monitoring in
19 | > production.
20 |
21 | ## SaaS vs Self-Hosted
22 |
23 | Sentry offers both a [SaaS solution](https://sentry.io/) and
24 | [self-hosted solution](https://develop.sentry.dev/self-hosted/). This guide
25 | assumes you are using SaaS but the guide still works with self-hosted with a few
26 | modifications.
27 |
28 | ## Signup
29 |
30 | You can sign up for Sentry and create a Remix project from visiting
31 | [this url](https://sentry.io/signup/?project_platform=javascript-remix) and
32 | filling out the signup form.
33 |
34 | ## Onboarding
35 |
36 | Once you see the onboarding page which has the DSN, copy that somewhere (this
37 | becomes `SENTRY_DSN`). Then click
38 | [this](https://sentry.io/orgredirect/settings/:orgslug/developer-settings/new-internal/)
39 | to create an internal integration. Give it a name and add the scope for
40 | `Releases:Admin`. Press Save, find the auth token at the bottom of the page
41 | under "Tokens", and copy that to secure location (this becomes
42 | `SENTRY_AUTH_TOKEN`). Then visit the organization settings page and copy that
43 | organization slug (`SENTRY_ORG_SLUG`).
44 |
45 | Now, set the secrets in Fly.io:
46 |
47 | ```sh
48 | fly secrets set SENTRY_DSN= SENTRY_AUTH_TOKEN= SENTRY_ORG= SENTRY_PROJECT=javascript-remix
49 | ```
50 |
51 | Note that `javascript-remix` is the name of the default Remix project in Sentry
52 | and if you use a different project name you'll need to update that value here.
53 |
--------------------------------------------------------------------------------
/content/docs/permissions/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Permissions
3 | date: 2023-11-02
4 | description: How user roles and permissions work in the Epic Stack.
5 | categories:
6 | - authorization
7 | - permissions
8 | meta:
9 | keywords:
10 | - permissions
11 | - roles
12 | - rbac
13 | ---
14 | The Epic Stack's Permissions model takes after
15 | [Role-Based Access Control (RBAC)](https://auth0.com/intro-to-iam/what-is-role-based-access-control-rbac).
16 | Each user has a set of roles, and each role has a set of permissions. A user's
17 | permissions are the union of the permissions of all their roles (with the more
18 | permissive permission taking precedence).
19 |
20 | The default development seed creates fine-grained permissions that include
21 | `create`, `read`, `update`, and `delete` permissions for `user` and `note` with
22 | the access of `own` and `any`. The default seed also creates `user` and `admin`
23 | roles with the sensible permissions for those roles.
24 |
25 | You can combine these permissions in different ways to support different roles
26 | for different personas of users of your application.
27 |
28 | The Epic Stack comes with built-in utilities for working with these permissions.
29 | Here are some examples to give you an idea:
30 |
31 | ```ts
32 | // server-side only utilities
33 | const userCanDeleteAnyUser = await requireUserWithPermission(
34 | request,
35 | 'delete:user:any',
36 | )
37 | const userIsAdmin = await requireUserWithRole(request, 'admin')
38 | ```
39 |
40 | ```ts
41 | // UI utilities
42 | const user = useUser()
43 | const userCanCreateTheirOwnNotes = userHasPermission(user, 'create:note:own')
44 | const userIsUser = userHasRole(user, 'user')
45 | ```
46 |
47 | There is currently no UI for managing permissions, but you can use prisma studio
48 | for establishing these.
49 |
50 | ## Seeding the production database
51 |
52 | Check [the deployment docs](./deployment.md) for instructions on how to seed the
53 | production database with the roles you want.
54 |
--------------------------------------------------------------------------------
/content/docs/secrets/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Secrets Management
3 | date: 2023-11-02
4 | description: Best practices for managing secrets in the Epic Stack.
5 | categories:
6 | - secrets
7 | meta:
8 | keywords:
9 | - secrets
10 | - security
11 | - flyio
12 | ---
13 | Managing secrets in the Epic Stack is done using environment variables and the
14 | `fly secrets` command.
15 |
16 | > **Warning**: It is very important that you do NOT hard code any secrets in the
17 | > source code. Even if your app source is not public, there are a lot of reasons
18 | > this is dangerous and in the epic stack we default to creating source maps
19 | > which will reveal your hard coded secrets to the public. Read more about this
20 | > in [the source map decision document](./decisions/016-source-maps.md).
21 |
22 | ## Local development
23 |
24 | When you need to create a new secret, it's best to add a line to your
25 | `.env.example` file so folks know that secret is necessary. The value you put in
26 | here should be not real because this file is committed to the repository.
27 |
28 | To keep everything in line with the [guiding principle](./guiding-principles.md)
29 | of "Offline Development," you should also strive make it so whatever service
30 | you're interacting with can be mocked out using MSW in the `test/mocks`
31 | directory.
32 |
33 | You can also put the real value of the secret in `.env` which is `.gitignore`d
34 | so you can interact with the real service if you need to during development.
35 |
36 | ## Production secrets
37 |
38 | To publish a secret to your production and staging applications, you can use the
39 | `fly secrets set` command. For example, if you were integrating with the `tito`
40 | API, to set the `TITO_API_SECRET` secret, you would run the following command:
41 |
42 | ```sh
43 | fly secrets set TITO_API_SECRET=some_secret_value
44 | fly secrets set TITO_API_SECRET=some_secret_value --app [YOUR_STAGING_APP_NAME]
45 | ```
46 |
47 | This will redeploy your app with that environment variable set.
48 |
--------------------------------------------------------------------------------
/content/docs/seo/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: SEO
3 | date: 2023-11-02
4 | description: Built-in SEO support including metadata, robots.txt, and sitemaps.
5 | categories:
6 | - seo
7 | meta:
8 | keywords:
9 | - seo
10 | - metadata
11 | - robots
12 | - sitemap
13 | ---
14 | Remix has built-in support for setting up `meta` tags on a per-route basis which
15 | you can read about
16 | [in the Remix Metadata docs](https://remix.run/docs/en/main/route/meta).
17 |
18 | The Epic Stack also has built-in support for `/robots.txt` and `/sitemap.xml`
19 | via [resource routes](https://remix.run/docs/en/main/guides/resource-routes)
20 | using [`@nasa-gcn/remix-seo`](https://github.com/nasa-gcn/remix-seo). By
21 | default, all routes are included in the `sitemap.xml` file, but you can
22 | configure which routes are included using the `handle` export in the route. Only
23 | public-facing pages should be included in the `sitemap.xml` file.
24 |
25 | Here are two quick examples of how to customize the sitemap on a per-route basis
26 | from the `@nasa-gcn/remix-seo` docs:
27 |
28 | ```tsx
29 | // routes/blog/$blogslug.tsx
30 |
31 | export const handle: SEOHandle = {
32 | getSitemapEntries: async request => {
33 | const blogs = await db.blog.findMany()
34 | return blogs.map(blog => {
35 | return { route: `/blog/${blog.slug}`, priority: 0.7 }
36 | })
37 | },
38 | }
39 | ```
40 |
41 | ```tsx
42 | // in your routes/url-that-doesnt-need-sitemap
43 | import { SEOHandle } from '@nasa-gcn/remix-seo'
44 |
45 | export let loader: LoaderFunction = ({ request }) => {
46 | /**/
47 | }
48 |
49 | export const handle: SEOHandle = {
50 | getSitemapEntries: () => null,
51 | }
52 | ```
53 |
--------------------------------------------------------------------------------
/content/docs/testing/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Testing
3 | date: 2023-11-02
4 | description: Testing stack including Playwright, Vitest, linting, formatting.
5 | categories:
6 | - testing
7 | meta:
8 | keywords:
9 | - testing
10 | - playwright
11 | - vitest
12 | - linting
13 | - formatting
14 | ---
15 | ## Playwright
16 |
17 | We use Playwright for our End-to-End tests in this project. You'll find those in
18 | the `tests` directory. As you make changes, add to an existing file or create a
19 | new file in the `tests` directory to test your changes.
20 |
21 | To run these tests in development, run `npm run test:e2e:dev` which will start
22 | the dev server for the app and run Playwright on it.
23 |
24 | We have a fixture for testing authenticated features without having to go
25 | through the login flow:
26 |
27 | ```ts
28 | test('my test', async ({ page, login }) => {
29 | const user = await login()
30 | // you are now logged in
31 | })
32 | ```
33 |
34 | We also auto-delete the user at the end of your test. That way, we can keep your
35 | local db clean and keep your tests isolated from one another.
36 |
37 | ## Vitest
38 |
39 | For lower level tests of utilities and individual components, we use `vitest`.
40 | We have DOM-specific assertion helpers via
41 | [`@testing-library/jest-dom`](https://testing-library.com/jest-dom).
42 |
43 | ## Type Checking
44 |
45 | This project uses TypeScript. It's recommended to get TypeScript set up for your
46 | editor to get a really great in-editor experience with type checking and
47 | auto-complete. To run type checking across the whole project, run
48 | `npm run typecheck`.
49 |
50 | ## Linting
51 |
52 | This project uses ESLint for linting. That is configured in `.eslintrc.js`.
53 |
54 | ## Formatting
55 |
56 | We use [Prettier](https://prettier.io/) for auto-formatting in this project.
57 | It's recommended to install an editor plugin (like the
58 | [VSCode Prettier plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode))
59 | to get auto-formatting on save. There's also a `npm run format` script you can
60 | run to format all files in the project.
61 |
--------------------------------------------------------------------------------
/content/docs/troubleshooting/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Troubleshooting
3 | date: 2023-11-02
4 | description: Solutions for common issues like CSP violations and missing icons.
5 | categories:
6 | - troubleshooting
7 | meta:
8 | keywords:
9 | - troubleshooting
10 | - errors
11 | - csp
12 | - icons
13 | ---
14 | This is the page where we document common errors and how to fix them:
15 |
16 | ## Content Security Policy violations
17 |
18 | If you've received an error like this:
19 |
20 | > Refused to load the image 'https://example.com/thing.png' because it violates
21 | > the following Content Security Policy directive: "img-src 'self'".
22 |
23 | This means you're trying to add a link to a resource that is not allowed. Learn
24 | more about the decision to add this content security policy (CSP) in
25 | [the decision document](./decisions/008-content-security-policy.md). NOTE: This
26 | is disabled by default as of
27 | [the report-only CSP decision](./decisions/022-report-only-csp.md). It is,
28 | however, recommended to be enabled for security reasons.
29 |
30 | To fix this, adjust the CSP to allow the resource you're trying to add. This can
31 | be done in the `server/index.ts` file.
32 |
33 | ```diff
34 | contentSecurityPolicy: {
35 | directives: {
36 | 'connect-src': [
37 | MODE === 'development' ? 'ws:' : null,
38 | process.env.SENTRY_DSN ? '*.ingest.sentry.io' : null,
39 | "'self'",
40 | ].filter(Boolean),
41 | 'font-src': ["'self'"],
42 | 'frame-src': ["'self'"],
43 | - 'img-src': ["'self'", 'data:'],
44 | + 'img-src': ["'self'", 'data:', 'https://*.example.com']
45 | ```
46 |
47 | ## Missing Icons
48 |
49 | Epic Stack uses SVG sprite icons for performance reasons. If you've received an
50 | error like this during local development:
51 |
52 | > X [ERROR] Could not resolve "#app/components/ui/icon.tsx"
53 |
54 | You need to be manually regenerate the icon with `npm run build:icons`.
55 |
56 | See
57 | [the icons decision document](https://github.com/epicweb-dev/epic-stack/blob/main/docs/decisions/020-icons.md)
58 | for more information about icons.
59 |
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | app = "epic-stack-docs-26ef"
2 | primary_region = "sjc"
3 | kill_signal = "SIGINT"
4 | kill_timeout = 5
5 | processes = [ ]
6 |
7 | [experimental]
8 | allowed_public_ports = [ ]
9 | auto_rollback = true
10 |
11 | [mounts]
12 | source = "data"
13 | destination = "/data"
14 |
15 | [deploy]
16 | release_command = "node ./other/sentry-create-release"
17 |
18 | [[services]]
19 | internal_port = 8080
20 | processes = [ "app" ]
21 | protocol = "tcp"
22 | script_checks = [ ]
23 |
24 | [services.concurrency]
25 | hard_limit = 100
26 | soft_limit = 80
27 | type = "requests"
28 |
29 | [[services.ports]]
30 | handlers = [ "http" ]
31 | port = 80
32 | force_https = true
33 |
34 | [[services.ports]]
35 | handlers = [ "tls", "http" ]
36 | port = 443
37 |
38 | [[services.tcp_checks]]
39 | grace_period = "1s"
40 | interval = "15s"
41 | restart_limit = 0
42 | timeout = "2s"
43 |
44 | [[services.http_checks]]
45 | interval = "10s"
46 | grace_period = "5s"
47 | method = "get"
48 | path = "/resources/healthcheck"
49 | protocol = "http"
50 | timeout = "2s"
51 | tls_skip_verify = false
52 | headers = { }
53 |
54 | [[services.http_checks]]
55 | grace_period = "10s"
56 | interval = "30s"
57 | method = "GET"
58 | timeout = "5s"
59 | path = "/litefs/health"
60 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import 'dotenv/config'
2 | import 'source-map-support/register.js'
3 | import { installGlobals } from '@remix-run/node'
4 | import chalk from 'chalk'
5 | import closeWithGrace from 'close-with-grace'
6 |
7 | installGlobals()
8 |
9 | closeWithGrace(async ({ err }) => {
10 | if (err) {
11 | console.error(chalk.red(err))
12 | console.error(chalk.red(err.stack))
13 | process.exit(1)
14 | }
15 | })
16 |
17 | if (process.env.MOCKS === 'true') {
18 | await import('./tests/mocks/index.ts')
19 | }
20 |
21 | if (process.env.NODE_ENV === 'production') {
22 | await import('./server-build/index.js')
23 | } else {
24 | await import('./server/index.ts')
25 | }
26 |
--------------------------------------------------------------------------------
/other/.dockerignore:
--------------------------------------------------------------------------------
1 | # This file is moved to the root directory before building the image
2 |
3 | /node_modules
4 | *.log
5 | .DS_Store
6 | .env
7 | /.cache
8 | /public/build
9 | /build
10 |
--------------------------------------------------------------------------------
/other/README.md:
--------------------------------------------------------------------------------
1 | # Other
2 |
3 | The "other" directory is where we put stuff that doesn't really have a place,
4 | but we don't want in the root of the project. In fact, we want to move as much
5 | stuff here from the root as possible. The only things that should stay in the
6 | root directory are those things that have to stay in the root for most editor
7 | and other tool integrations (like most configuration files sadly). Maybe one day
8 | we can convince tools to adopt a new `.config` directory in the future. Until
9 | then, we've got this `./other` directory to keep things cleaner.
10 |
--------------------------------------------------------------------------------
/other/build-server.ts:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import { fileURLToPath } from 'url'
3 | import esbuild from 'esbuild'
4 | import fsExtra from 'fs-extra'
5 | import { globSync } from 'glob'
6 |
7 | const pkg = fsExtra.readJsonSync(path.join(process.cwd(), 'package.json'))
8 |
9 | const __dirname = path.dirname(fileURLToPath(import.meta.url))
10 | const here = (...s: Array) => path.join(__dirname, ...s)
11 | const globsafe = (s: string) => s.replace(/\\/g, '/')
12 |
13 | const allFiles = globSync(globsafe(here('../server/**/*.*')), {
14 | ignore: [
15 | 'server/dev-server.js', // for development only
16 | '**/tsconfig.json',
17 | '**/eslint*',
18 | '**/__tests__/**',
19 | ],
20 | })
21 |
22 | const entries = []
23 | for (const file of allFiles) {
24 | if (/\.(ts|js|tsx|jsx)$/.test(file)) {
25 | entries.push(file)
26 | } else {
27 | const dest = file.replace(here('../server'), here('../server-build'))
28 | fsExtra.ensureDirSync(path.parse(dest).dir)
29 | fsExtra.copySync(file, dest)
30 | console.log(`copied: ${file.replace(`${here('../server')}/`, '')}`)
31 | }
32 | }
33 |
34 | console.log()
35 | console.log('building...')
36 |
37 | esbuild
38 | .build({
39 | entryPoints: entries,
40 | outdir: here('../server-build'),
41 | target: [`node${pkg.engines.node}`],
42 | platform: 'node',
43 | sourcemap: true,
44 | format: 'esm',
45 | logLevel: 'info',
46 | })
47 | .catch((error: unknown) => {
48 | console.error(error)
49 | process.exit(1)
50 | })
51 |
--------------------------------------------------------------------------------
/other/litefs.yml:
--------------------------------------------------------------------------------
1 | # Documented example: https://github.com/superfly/litefs/blob/dec5a7353292068b830001bd2df4830e646f6a2f/cmd/litefs/etc/litefs.yml
2 | fuse:
3 | # Required. This is the mount directory that applications will
4 | # use to access their SQLite databases.
5 | dir: '${LITEFS_DIR}'
6 |
7 | data:
8 | # Path to internal data storage.
9 | dir: '/data/litefs'
10 |
11 | proxy:
12 | # matches the internal_port in fly.toml
13 | addr: ':${INTERNAL_PORT}'
14 | target: 'localhost:${PORT}'
15 | db: '${DATABASE_FILENAME}'
16 |
17 | # The lease section specifies how the cluster will be managed. We're using the
18 | # "consul" lease type so that our application can dynamically change the primary.
19 | #
20 | # These environment variables will be available in your Fly.io application.
21 | lease:
22 | type: 'consul'
23 | candidate: ${FLY_REGION == PRIMARY_REGION}
24 | promote: true
25 | advertise-url: 'http://${HOSTNAME}.vm.${FLY_APP_NAME}.internal:20202'
26 |
27 | consul:
28 | url: '${FLY_CONSUL_URL}'
29 | key: 'epic-stack-litefs/${FLY_APP_NAME}'
30 |
31 | exec:
32 | - cmd: node ./other/setup-swap.js
33 |
34 | - cmd: npx prisma migrate deploy
35 | if-candidate: true
36 |
37 | - cmd: npm start
38 |
--------------------------------------------------------------------------------
/other/sentry-create-release.js:
--------------------------------------------------------------------------------
1 | import { createRelease } from '@sentry/remix/scripts/createRelease.js'
2 | import 'dotenv/config'
3 |
4 | const DEFAULT_URL_PREFIX = '#build/'
5 | const DEFAULT_BUILD_PATH = 'public/build'
6 |
7 | // exit with non-zero code if we have everything for Sentry
8 | if (
9 | process.env.SENTRY_DSN &&
10 | process.env.SENTRY_ORG &&
11 | process.env.SENTRY_PROJECT &&
12 | process.env.SENTRY_AUTH_TOKEN
13 | ) {
14 | createRelease({}, DEFAULT_URL_PREFIX, DEFAULT_BUILD_PATH)
15 | } else {
16 | console.log(
17 | 'Missing Sentry environment variables, skipping sourcemap upload.',
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/other/setup-swap.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import { writeFile } from 'node:fs/promises'
4 | import { $ } from 'execa'
5 |
6 | console.log('setting up swapfile...')
7 | await $`fallocate -l 512M /swapfile`
8 | await $`chmod 0600 /swapfile`
9 | await $`mkswap /swapfile`
10 | await writeFile('/proc/sys/vm/swappiness', '10')
11 | await $`swapon /swapfile`
12 | await writeFile('/proc/sys/vm/overcommit_memory', '1')
13 | console.log('swapfile setup complete')
14 |
--------------------------------------------------------------------------------
/other/sly/sly.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://sly-cli.fly.dev/registry/config.json",
3 | "libraries": [
4 | {
5 | "name": "@radix-ui/icons",
6 | "directory": "./other/svg-icons",
7 | "postinstall": ["npm", "run", "build:icons"],
8 | "transformers": ["transform-icon.ts"]
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/other/sly/transform-icon.ts:
--------------------------------------------------------------------------------
1 | import { type Meta } from '@sly-cli/sly'
2 |
3 | /**
4 | * @type {import('@sly-cli/sly/dist').Transformer}
5 | */
6 | export default function transformIcon(input: string, meta: Meta) {
7 | input = prependLicenseInfo(input, meta)
8 |
9 | return input
10 | }
11 |
12 | function prependLicenseInfo(input: string, meta: Meta): string {
13 | return [
14 | ``,
15 | ``,
16 | ``,
17 | input,
18 | ].join('\n')
19 | }
20 |
--------------------------------------------------------------------------------
/other/svg-icons/README.md:
--------------------------------------------------------------------------------
1 | # Icons
2 |
3 | These icons were downloaded from https://icons.radix-ui.com/ which is licensed
4 | under MIT: https://github.com/radix-ui/icons/blob/master/LICENSE
5 |
6 | It's important that you only add icons to this directory that the application
7 | actually needs as there's no "tree-shaking" for sprites. If you wish to manually
8 | split up your SVG sprite into multiple files, you'll need to update the
9 | `build-icons.ts` script to do that.
10 |
11 | Run `npm run build:icons` to update the sprite.
12 |
--------------------------------------------------------------------------------
/other/svg-icons/arrow-left.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/arrow-right.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/avatar.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/camera.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/check.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/chevron-down.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
14 |
--------------------------------------------------------------------------------
/other/svg-icons/clock.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/cross-1.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/dots-horizontal.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
14 |
--------------------------------------------------------------------------------
/other/svg-icons/download.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
14 |
--------------------------------------------------------------------------------
/other/svg-icons/envelope-closed.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
14 |
--------------------------------------------------------------------------------
/other/svg-icons/epic-logo-light.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/other/svg-icons/epic-logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/other/svg-icons/exit.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/file-text.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/github-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
14 |
--------------------------------------------------------------------------------
/other/svg-icons/hamburger-menu.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
14 |
--------------------------------------------------------------------------------
/other/svg-icons/laptop.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/lock-closed.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/lock-open-1.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/magnifying-glass.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/pencil-1.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/plus.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/question-mark-circled.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
14 |
--------------------------------------------------------------------------------
/other/svg-icons/reset.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/sun.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/trash.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/other/svg-icons/update.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from '@playwright/test'
2 | import 'dotenv/config'
3 |
4 | const PORT = process.env.PORT || '3000'
5 |
6 | export default defineConfig({
7 | testDir: './tests/e2e',
8 | timeout: 15 * 1000,
9 | expect: {
10 | timeout: 5 * 1000,
11 | },
12 | fullyParallel: true,
13 | forbidOnly: !!process.env.CI,
14 | retries: process.env.CI ? 2 : 0,
15 | workers: process.env.CI ? 1 : undefined,
16 | reporter: 'html',
17 | use: {
18 | baseURL: `http://localhost:${PORT}/`,
19 | trace: 'on-first-retry',
20 | },
21 |
22 | projects: [
23 | {
24 | name: 'chromium',
25 | use: {
26 | ...devices['Desktop Chrome'],
27 | },
28 | },
29 | ],
30 |
31 | webServer: {
32 | command: process.env.CI ? 'npm run start:mocks' : 'npm run dev',
33 | port: Number(PORT),
34 | reuseExistingServer: !process.env.CI,
35 | stdout: 'pipe',
36 | stderr: 'pipe',
37 | env: {
38 | PORT,
39 | },
40 | },
41 | })
42 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | 'tailwindcss/nesting': {},
4 | tailwindcss: {},
5 | autoprefixer: {},
6 | },
7 | }
8 |
--------------------------------------------------------------------------------
/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"
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicons/README.md:
--------------------------------------------------------------------------------
1 | # Favicon
2 |
3 | This directory has a few versions of icons to account for different devices. In
4 | some cases, we cannot reliably detect light/dark mode preference. Hence some of
5 | the icons in here should not have a transparent background. These icons are
6 | referenced in the `site.webmanifest` file.
7 |
8 | Note, there's also a `favicon.ico` in the root of `/public` which some older
9 | browsers will request automatically. This is a fallback for those browsers.
10 |
--------------------------------------------------------------------------------
/public/favicons/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/favicons/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/favicons/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/favicons/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/favicons/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/favicons/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/favicons/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/favicons/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicons/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/favicons/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicons/favicon.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/public/favicons/mask-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200.woff
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200.woff2
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200italic.woff
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200italic.woff2
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300.woff
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300.woff2
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300italic.woff
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-300italic.woff2
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600.woff
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600.woff2
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600italic.woff
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-600italic.woff2
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700.woff
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700.woff2
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700italic.woff
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-700italic.woff2
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800.woff
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800.woff2
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800italic.woff
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-800italic.woff2
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900.woff
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900.woff2
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900italic.woff
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-900italic.woff2
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-italic.woff
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-italic.woff2
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-regular.woff
--------------------------------------------------------------------------------
/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-regular.woff2
--------------------------------------------------------------------------------
/public/img/user.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/public/img/user.png
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Epic Notes",
3 | "short_name": "Epic Notes",
4 | "start_url": "/",
5 | "icons": [
6 | {
7 | "src": "/favicons/android-chrome-192x192.png",
8 | "sizes": "192x192",
9 | "type": "image/png"
10 | },
11 | {
12 | "src": "/favicons/android-chrome-512x512.png",
13 | "sizes": "512x512",
14 | "type": "image/png"
15 | }
16 | ],
17 | "theme_color": "#A9ADC1",
18 | "background_color": "#1f2028",
19 | "display": "standalone"
20 | }
21 |
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | import { flatRoutes } from 'remix-flat-routes'
2 |
3 | /**
4 | * @type {import('@remix-run/dev').AppConfig}
5 | */
6 | export default {
7 | cacheDirectory: './node_modules/.cache/remix',
8 | ignoredRouteFiles: ['**/*'],
9 | serverModuleFormat: 'esm',
10 | serverPlatform: 'node',
11 | tailwind: true,
12 | postcss: true,
13 | watchPaths: ['./tailwind.config.ts'],
14 | routes: async defineRoutes => {
15 | return flatRoutes('routes', defineRoutes, {
16 | ignoredRouteFiles: [
17 | '.*',
18 | '**/*.css',
19 | '**/*.test.{js,jsx,ts,tsx}',
20 | '**/__*.*',
21 | ],
22 | })
23 | },
24 | }
25 |
--------------------------------------------------------------------------------
/server/dev-server.js:
--------------------------------------------------------------------------------
1 | import { execa } from 'execa'
2 |
3 | if (process.env.NODE_ENV === 'production') {
4 | await import('./index.js')
5 | } else {
6 | const command =
7 | 'tsx watch --clear-screen=false --ignore "app/**" --ignore "build/**" --ignore "node_modules/**" --inspect ./index.js'
8 | execa(command, {
9 | stdio: ['ignore', 'inherit', 'inherit'],
10 | shell: true,
11 | env: {
12 | FORCE_COLOR: true,
13 | MOCKS: true,
14 | ...process.env,
15 | },
16 | // https://github.com/sindresorhus/execa/issues/433
17 | windowsHide: false,
18 | })
19 | }
20 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import typography from '@tailwindcss/typography'
2 | import { type Config } from 'tailwindcss'
3 | import defaultTheme from 'tailwindcss/defaultTheme.js'
4 | import animatePlugin from 'tailwindcss-animate'
5 | import radixPlugin from 'tailwindcss-radix'
6 | import { extendedTheme } from './app/utils/extended-theme.ts'
7 |
8 | export default {
9 | content: ['./app/**/*.{ts,tsx,jsx,js}'],
10 | darkMode: 'class',
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: '2rem',
15 | screens: {
16 | '2xl': '1400px',
17 | },
18 | },
19 | extend: {
20 | ...extendedTheme,
21 | fontFamily: {
22 | sans: ['var(--font-sans)', ...defaultTheme.fontFamily.sans],
23 | },
24 | },
25 | },
26 | plugins: [animatePlugin, radixPlugin, typography],
27 | } satisfies Config
28 |
--------------------------------------------------------------------------------
/tests/e2e/2fa.test.ts:
--------------------------------------------------------------------------------
1 | import { faker } from '@faker-js/faker'
2 | import { generateTOTP } from '#app/utils/totp.server.ts'
3 | import { expect, test } from '#tests/playwright-utils.ts'
4 |
5 | test('Users can add 2FA to their account and use it when logging in', async ({
6 | page,
7 | login,
8 | }) => {
9 | const password = faker.internet.password()
10 | const user = await login({ password })
11 | await page.goto('/settings/profile')
12 |
13 | await page.getByRole('link', { name: /enable 2fa/i }).click()
14 |
15 | await expect(page).toHaveURL(`/settings/profile/two-factor`)
16 | const main = page.getByRole('main')
17 | await main.getByRole('button', { name: /enable 2fa/i }).click()
18 | const otpUriString = await main
19 | .getByLabel(/One-Time Password URI/i)
20 | .innerText()
21 |
22 | const otpUri = new URL(otpUriString)
23 | const options = Object.fromEntries(otpUri.searchParams)
24 |
25 | await main
26 | .getByRole('textbox', { name: /code/i })
27 | .fill(generateTOTP(options).otp)
28 | await main.getByRole('button', { name: /submit/i }).click()
29 |
30 | await expect(main).toHaveText(/You have enabled two-factor authentication./i)
31 | await expect(main.getByRole('link', { name: /disable 2fa/i })).toBeVisible()
32 |
33 | await page.getByRole('link', { name: user.name ?? user.username }).click()
34 | await page.getByRole('button', { name: /logout/i }).click()
35 | await expect(page).toHaveURL(`/`)
36 |
37 | await page.goto('/login')
38 | await expect(page).toHaveURL(`/login`)
39 | await page.getByRole('textbox', { name: /username/i }).fill(user.username)
40 | await page.getByLabel(/^password$/i).fill(password)
41 | await page.getByRole('button', { name: /log in/i }).click()
42 |
43 | await page
44 | .getByRole('textbox', { name: /code/i })
45 | .fill(generateTOTP(options).otp)
46 |
47 | await page.getByRole('button', { name: /submit/i }).click()
48 |
49 | await expect(
50 | page.getByRole('link', { name: user.name ?? user.username }),
51 | ).toBeVisible()
52 | })
53 |
--------------------------------------------------------------------------------
/tests/e2e/error-boundary.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from '#tests/playwright-utils.ts'
2 |
3 | test('Test root error boundary caught', async ({ page }) => {
4 | const pageUrl = '/does-not-exist'
5 | const res = await page.goto(pageUrl)
6 |
7 | expect(res?.status()).toBe(404)
8 | await expect(page.getByText(/We can't find this page/i)).toBeVisible()
9 | })
10 |
--------------------------------------------------------------------------------
/tests/fixtures/github/ghost.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/github/ghost.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/kody-notes/cute-koala.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/kody-notes/cute-koala.png
--------------------------------------------------------------------------------
/tests/fixtures/images/kody-notes/koala-coder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/kody-notes/koala-coder.png
--------------------------------------------------------------------------------
/tests/fixtures/images/kody-notes/koala-cuddle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/kody-notes/koala-cuddle.png
--------------------------------------------------------------------------------
/tests/fixtures/images/kody-notes/koala-eating.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/kody-notes/koala-eating.png
--------------------------------------------------------------------------------
/tests/fixtures/images/kody-notes/koala-mentor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/kody-notes/koala-mentor.png
--------------------------------------------------------------------------------
/tests/fixtures/images/kody-notes/koala-soccer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/kody-notes/koala-soccer.png
--------------------------------------------------------------------------------
/tests/fixtures/images/kody-notes/mountain.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/kody-notes/mountain.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/notes/0.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/notes/1.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/notes/2.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/notes/3.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/notes/4.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/notes/5.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/notes/6.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/notes/7.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/notes/8.png
--------------------------------------------------------------------------------
/tests/fixtures/images/notes/9.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/notes/9.png
--------------------------------------------------------------------------------
/tests/fixtures/images/user/0.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/user/0.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/user/1.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/user/2.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/user/3.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/user/4.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/5.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/user/5.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/6.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/user/6.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/7.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/user/7.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/8.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/user/8.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/9.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/user/9.jpg
--------------------------------------------------------------------------------
/tests/fixtures/images/user/README.md:
--------------------------------------------------------------------------------
1 | # User Images
2 |
3 | This is used when creating users with images. If you don't do that, feel free to
4 | delete this directory.
5 |
--------------------------------------------------------------------------------
/tests/fixtures/images/user/kody.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/epicweb-dev/epic-stack-docs/d2d6046ca01579740c7a9e9c1d88f555ad5cff39/tests/fixtures/images/user/kody.png
--------------------------------------------------------------------------------
/tests/mocks/README.md:
--------------------------------------------------------------------------------
1 | # Mocks
2 |
3 | Use this to mock any third party HTTP resources that you don't have running
4 | locally and want to have mocked for local development as well as tests.
5 |
6 | Learn more about how to use this at [mswjs.io](https://mswjs.io/)
7 |
8 | For an extensive example, see the
9 | [source code for kentcdodds.com](https://github.com/kentcdodds/kentcdodds.com/blob/main/mocks/index.ts)
10 |
--------------------------------------------------------------------------------
/tests/mocks/index.ts:
--------------------------------------------------------------------------------
1 | import closeWithGrace from 'close-with-grace'
2 | import { passthrough, http } from 'msw'
3 | import { setupServer } from 'msw/node'
4 | import { handlers as githubHandlers } from './github.ts'
5 | import { handlers as resendHandlers } from './resend.ts'
6 |
7 | const miscHandlers = [
8 | process.env.REMIX_DEV_ORIGIN
9 | ? http.post(`${process.env.REMIX_DEV_ORIGIN}ping`, passthrough)
10 | : null,
11 | ].filter(Boolean)
12 |
13 | export const server = setupServer(
14 | ...miscHandlers,
15 | ...resendHandlers,
16 | ...githubHandlers,
17 | )
18 |
19 | server.listen({ onUnhandledRequest: 'warn' })
20 |
21 | if (process.env.NODE_ENV !== 'test') {
22 | console.info('🔶 Mock server installed')
23 |
24 | closeWithGrace(() => {
25 | server.close()
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/tests/mocks/resend.ts:
--------------------------------------------------------------------------------
1 | import { faker } from '@faker-js/faker'
2 | import { HttpResponse, http, type HttpHandler } from 'msw'
3 | import { requireHeader, writeEmail } from './utils.ts'
4 |
5 | const { json } = HttpResponse
6 |
7 | export const handlers: Array = [
8 | http.post(`https://api.resend.com/emails`, async ({ request }) => {
9 | requireHeader(request.headers, 'Authorization')
10 | const body = await request.json()
11 | console.info('🔶 mocked email contents:', body)
12 |
13 | const email = await writeEmail(body)
14 |
15 | return json({
16 | id: faker.string.uuid(),
17 | from: email.from,
18 | to: email.to,
19 | created_at: new Date().toISOString(),
20 | })
21 | }),
22 | ]
23 |
--------------------------------------------------------------------------------
/tests/mocks/utils.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import { fileURLToPath } from 'node:url'
3 | import fsExtra from 'fs-extra'
4 | import { z } from 'zod'
5 |
6 | const __dirname = path.dirname(fileURLToPath(import.meta.url))
7 | const fixturesDirPath = path.join(__dirname, '..', 'fixtures')
8 |
9 | export async function readFixture(subdir: string, name: string) {
10 | return fsExtra.readJSON(path.join(fixturesDirPath, subdir, `${name}.json`))
11 | }
12 |
13 | export async function createFixture(
14 | subdir: string,
15 | name: string,
16 | data: unknown,
17 | ) {
18 | const dir = path.join(fixturesDirPath, subdir)
19 | await fsExtra.ensureDir(dir)
20 | return fsExtra.writeJSON(path.join(dir, `./${name}.json`), data)
21 | }
22 |
23 | export const EmailSchema = z.object({
24 | to: z.string(),
25 | from: z.string(),
26 | subject: z.string(),
27 | text: z.string(),
28 | html: z.string(),
29 | })
30 |
31 | export async function writeEmail(rawEmail: unknown) {
32 | const email = EmailSchema.parse(rawEmail)
33 | await createFixture('email', email.to, email)
34 | return email
35 | }
36 |
37 | export async function requireEmail(recipient: string) {
38 | const email = await readEmail(recipient)
39 | if (!email) throw new Error(`Email to ${recipient} not found`)
40 | return email
41 | }
42 |
43 | export async function readEmail(recipient: string) {
44 | try {
45 | const email = await readFixture('email', recipient)
46 | return EmailSchema.parse(email)
47 | } catch (error) {
48 | console.error(`Error reading email`, error)
49 | return null
50 | }
51 | }
52 |
53 | export function requireHeader(headers: Headers, header: string) {
54 | if (!headers.has(header)) {
55 | const headersString = JSON.stringify(
56 | Object.fromEntries(headers.entries()),
57 | null,
58 | 2,
59 | )
60 | throw new Error(
61 | `Header "${header}" required, but not found in ${headersString}`,
62 | )
63 | }
64 | return headers.get(header)
65 | }
66 |
--------------------------------------------------------------------------------
/tests/setup/db-setup.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import fsExtra from 'fs-extra'
3 | import { afterAll, afterEach, beforeAll } from 'vitest'
4 | import { cleanupDb } from '#tests/db-utils.ts'
5 | import { BASE_DATABASE_PATH } from './global-setup.ts'
6 |
7 | const databaseFile = `./tests/prisma/data.${process.env.VITEST_POOL_ID || 0}.db`
8 | const databasePath = path.join(process.cwd(), databaseFile)
9 | process.env.DATABASE_URL = `file:${databasePath}`
10 |
11 | beforeAll(async () => {
12 | await fsExtra.copyFile(BASE_DATABASE_PATH, databasePath)
13 | })
14 |
15 | // we *must* use dynamic imports here so the process.env.DATABASE_URL is set
16 | // before prisma is imported and initialized
17 | afterEach(async () => {
18 | const { prisma } = await import('#app/utils/db.server.ts')
19 | await cleanupDb(prisma)
20 | })
21 |
22 | afterAll(async () => {
23 | const { prisma } = await import('#app/utils/db.server.ts')
24 | await prisma.$disconnect()
25 | await fsExtra.remove(databasePath)
26 | })
27 |
--------------------------------------------------------------------------------
/tests/setup/global-setup.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import { execaCommand } from 'execa'
3 | import fsExtra from 'fs-extra'
4 |
5 | export const BASE_DATABASE_PATH = path.join(
6 | process.cwd(),
7 | `./tests/prisma/base.db`,
8 | )
9 |
10 | export async function setup() {
11 | const databaseExists = await fsExtra.pathExists(BASE_DATABASE_PATH)
12 | if (databaseExists) return
13 |
14 | await execaCommand('prisma migrate reset --force --skip-generate', {
15 | stdio: 'inherit',
16 | env: {
17 | ...process.env,
18 | MINIMAL_SEED: 'true',
19 | DATABASE_URL: `file:${BASE_DATABASE_PATH}`,
20 | },
21 | })
22 | }
23 |
--------------------------------------------------------------------------------
/tests/setup/setup-test-env.ts:
--------------------------------------------------------------------------------
1 | import 'dotenv/config'
2 | import './db-setup.ts'
3 | import '#app/utils/env.server.ts'
4 | // we need these to be imported first 👆
5 |
6 | import { installGlobals } from '@remix-run/node'
7 | import { cleanup } from '@testing-library/react'
8 | import { afterEach, beforeEach, vi, type SpyInstance } from 'vitest'
9 | import { server } from '#tests/mocks/index.ts'
10 | import './custom-matchers.ts'
11 |
12 | installGlobals()
13 |
14 | afterEach(() => server.resetHandlers())
15 | afterEach(() => cleanup())
16 |
17 | export let consoleError: SpyInstance>
18 |
19 | beforeEach(() => {
20 | const originalConsoleError = console.error
21 | consoleError = vi.spyOn(console, 'error')
22 | consoleError.mockImplementation(
23 | (...args: Parameters) => {
24 | originalConsoleError(...args)
25 | throw new Error(
26 | 'Console error was called. Call consoleError.mockImplementation(() => {}) if this is expected.',
27 | )
28 | },
29 | )
30 | })
31 |
--------------------------------------------------------------------------------
/tests/utils.ts:
--------------------------------------------------------------------------------
1 | import * as setCookieParser from 'set-cookie-parser'
2 | import { sessionKey } from '#app/utils/auth.server.ts'
3 | import { authSessionStorage } from '#app/utils/session.server.ts'
4 |
5 | export const BASE_URL = 'https://www.epicstack.dev'
6 |
7 | export function convertSetCookieToCookie(setCookie: string) {
8 | const parsedCookie = setCookieParser.parseString(setCookie)
9 | return new URLSearchParams({
10 | [parsedCookie.name]: parsedCookie.value,
11 | }).toString()
12 | }
13 |
14 | export async function getSessionSetCookieHeader(
15 | session: { id: string },
16 | existingCookie?: string,
17 | ) {
18 | const authSession = await authSessionStorage.getSession(existingCookie)
19 | authSession.set(sessionKey, session.id)
20 | const setCookieHeader = await authSessionStorage.commitSession(authSession)
21 | return setCookieHeader
22 | }
23 |
24 | export async function getSessionCookieHeader(
25 | session: { id: string },
26 | existingCookie?: string,
27 | ) {
28 | const setCookieHeader = await getSessionSetCookieHeader(
29 | session,
30 | existingCookie,
31 | )
32 | return convertSetCookieToCookie(setCookieHeader)
33 | }
34 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["**/*.ts", "**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
5 | "isolatedModules": true,
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "module": "nodenext",
9 | "moduleResolution": "nodenext",
10 | "resolveJsonModule": true,
11 | "target": "ES2022",
12 | "strict": true,
13 | "noImplicitAny": true,
14 | "allowJs": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "paths": {
17 | "#*": ["./*"],
18 | "@/icon-name": [
19 | "./app/components/ui/icons/name.d.ts",
20 | "./types/icon-name.d.ts"
21 | ]
22 | },
23 | "skipLibCheck": true,
24 | "allowImportingTsExtensions": true,
25 | "noEmit": true
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/types/deps.d.ts:
--------------------------------------------------------------------------------
1 | // This module should contain type definitions for modules which do not have
2 | // their own type definitions and are not available on DefinitelyTyped.
3 |
4 | declare module 'tailwindcss-animate' {
5 | declare const _default: {
6 | handler: () => void
7 | }
8 | export = _default
9 | }
10 |
--------------------------------------------------------------------------------
/types/icon-name.d.ts:
--------------------------------------------------------------------------------
1 | // This file is a fallback until you run npm run build:icons
2 |
3 | export type IconName = string
4 |
--------------------------------------------------------------------------------
/types/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/types/reset.d.ts:
--------------------------------------------------------------------------------
1 | // Do not add any other lines of code to this file!
2 | import '@total-typescript/ts-reset/dom'
3 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import react from '@vitejs/plugin-react'
4 | import { defineConfig } from 'vite'
5 |
6 | export default defineConfig({
7 | plugins: [react()],
8 | css: { postcss: { plugins: [] } },
9 | test: {
10 | include: ['./app/**/*.test.{ts,tsx}'],
11 | setupFiles: ['./tests/setup/setup-test-env.ts'],
12 | globalSetup: ['./tests/setup/global-setup.ts'],
13 | restoreMocks: true,
14 | coverage: {
15 | include: ['app/**/*.{ts,tsx}'],
16 | all: true,
17 | },
18 | },
19 | })
20 |
--------------------------------------------------------------------------------