├── .gitignore ├── .vscode └── remix-workshop.code-workspace ├── LICENSE.md ├── README.md ├── error-logging ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .vscode │ └── launch.json ├── README.md ├── app │ ├── data │ │ └── users.json │ ├── entry.client.tsx │ ├── entry.server.tsx │ ├── models.server │ │ └── user.ts │ ├── root.tsx │ ├── routes │ │ ├── error.tsx │ │ ├── index.tsx │ │ ├── users.$userId_.edit.tsx │ │ └── users._index.tsx │ ├── styles │ │ └── tailwind.css │ └── utils │ │ ├── data.ts │ │ ├── params.ts │ │ ├── responses.ts │ │ ├── serialize.ts │ │ └── typedjson │ │ ├── index.ts │ │ ├── remix.ts │ │ └── typedjson.ts ├── package-lock.json ├── package.json ├── patches │ ├── @remix-run+dev+1.7.5.patch │ └── @remix-run+server-runtime+1.7.5.patch ├── public │ └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── scripts │ └── upload-sourcemap ├── slides │ ├── 00.md │ ├── 01.md │ ├── 02.md │ ├── 03.md │ └── 99.md ├── tailwind.config.js └── tsconfig.json ├── multi-page-forms ├── .eslintrc.js ├── .gitignore ├── README.md ├── app │ ├── entry.client.tsx │ ├── entry.server.tsx │ ├── root.tsx │ ├── routes │ │ └── index.tsx │ ├── session.server.ts │ └── styles │ │ └── tailwind.css ├── package-lock.json ├── package.json ├── public │ └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── slides │ ├── 00.md │ ├── 01.md │ ├── 02.md │ └── 99.md ├── tailwind.config.js └── tsconfig.json ├── multi-tenant-prisma ├── .env.example ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── app │ ├── entry.client.tsx │ ├── entry.server.tsx │ ├── models │ │ ├── note.server.ts │ │ ├── tenant.server.ts │ │ └── user.server.ts │ ├── prisma │ │ ├── db.server.ts │ │ ├── public.ts │ │ └── tenant.ts │ ├── root.tsx │ ├── routes │ │ ├── healthcheck.tsx │ │ ├── index.tsx │ │ ├── join.tsx │ │ ├── login.tsx │ │ ├── logout.tsx │ │ ├── notes.tsx │ │ └── notes │ │ │ ├── $noteId.tsx │ │ │ ├── index.tsx │ │ │ └── new.tsx │ ├── services │ │ └── tenant.server.ts │ ├── session.server.ts │ ├── utils.test.ts │ └── utils.ts ├── docker-compose.yml ├── package-lock.json ├── package.json ├── prisma │ ├── get-tenants.ts │ ├── prisma-generate │ ├── prisma-migrate-dev │ ├── prisma-migrate-dev-all │ ├── prisma-seed-dev │ ├── prisma-seed-dev-all │ ├── public │ │ ├── migrations │ │ │ ├── 20221117204758_initial_migration │ │ │ │ └── migration.sql │ │ │ └── migration_lock.toml │ │ ├── schema.prisma │ │ └── seed.ts │ ├── setup │ └── tenant │ │ ├── migrations │ │ ├── 20221117205457_initial_migration │ │ │ └── migration.sql │ │ ├── 20221120175227_add_field_tags │ │ │ └── migration.sql │ │ └── migration_lock.toml │ │ ├── schema.prisma │ │ └── seed.ts ├── public │ └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── slides │ ├── 00.md │ ├── 01.md │ ├── 02.md │ ├── 03.md │ ├── 04.md │ ├── 05.md │ ├── 06.md │ ├── 07.md │ └── 99.md ├── tailwind.config.js └── tsconfig.json ├── patch-tool ├── apply-patches ├── pull-remix └── slides │ ├── 00.md │ ├── 01.md │ ├── 02.md │ ├── 03.md │ └── 99.md └── remix-vitest ├── .eslintrc ├── .github └── workflows │ ├── integration.yaml │ └── test.yaml ├── .gitignore ├── .vscode ├── -settings.json └── extensions.json ├── README.md ├── app ├── entry.client.tsx ├── entry.server.tsx ├── mocks.tsx ├── root.tsx ├── routes │ ├── about.tsx │ └── index.tsx └── styles │ └── pico.css ├── e2e └── example.spec.ts ├── package-lock.json ├── package.json ├── playwright.config.ts ├── public └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── slides ├── 00.md ├── 01.md ├── 02.md ├── 03.md ├── 04.md ├── 05.md ├── 06.md └── 99.md ├── tsconfig.json ├── vitest.config.ts └── vitest.setup.ts /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store -------------------------------------------------------------------------------- /.vscode/remix-workshop.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "../error-logging" 5 | }, 6 | { 7 | "path": "../multi-page-forms" 8 | }, 9 | { 10 | "path": "../multi-tenant-prisma" 11 | }, 12 | { 13 | "path": "../patch-tool" 14 | }, 15 | { 16 | "path": "../remix-vitest" 17 | } 18 | ], 19 | "settings": {} 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Michael Carter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix Workshop 2 | 3 | Repositories used for Remix Conf Europe 2022 Remote Workshop 4 | 5 | - error-logging 6 | - multi-page-forms 7 | - patch-tool 8 | - multi-tenant-prisma 9 | 10 | Repository for Discussion Group on Testing 11 | 12 | - remix-vitest 13 | 14 | 👋 I'm Michael Carter aka Kiliman 15 | 16 | - Repo https://github.com/kiliman/remix-workshop 17 | - Twitter https://twitter.com/kiliman 18 | - GitHub https://github.com/kiliman 19 | - Blog https://kiliman.dev 20 | -------------------------------------------------------------------------------- /error-logging/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | }; 5 | -------------------------------------------------------------------------------- /error-logging/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | -------------------------------------------------------------------------------- /error-logging/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "arrowParens": "avoid" 6 | } -------------------------------------------------------------------------------- /error-logging/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "command": "npm run dev", 9 | "name": "Run npm run dev", 10 | "request": "launch", 11 | "type": "node-terminal", 12 | "cwd": "${workspaceFolder}" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /error-logging/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix! 2 | 3 | - [Remix Docs](https://remix.run/docs) 4 | 5 | ## Development 6 | 7 | From your terminal: 8 | 9 | ```sh 10 | npm run dev 11 | ``` 12 | 13 | This starts your app in development mode, rebuilding assets on file changes. 14 | 15 | ## Deployment 16 | 17 | First, build your app for production: 18 | 19 | ```sh 20 | npm run build 21 | ``` 22 | 23 | Then run the app in production mode: 24 | 25 | ```sh 26 | npm start 27 | ``` 28 | 29 | Now you'll need to pick a host to deploy it to. 30 | 31 | ### DIY 32 | 33 | If you're familiar with deploying node applications, the built-in Remix app server is production-ready. 34 | 35 | Make sure to deploy the output of `remix build` 36 | 37 | - `build/` 38 | - `public/build/` 39 | 40 | ### Using a Template 41 | 42 | When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server. 43 | 44 | ```sh 45 | cd .. 46 | # create a new project, and pick a pre-configured host 47 | npx create-remix@latest 48 | cd my-new-remix-app 49 | # remove the new project's app (not the old one!) 50 | rm -rf app 51 | # copy your app over 52 | cp -R ../my-old-remix-app/app app 53 | ``` 54 | -------------------------------------------------------------------------------- /error-logging/app/data/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "Kiliman", 5 | "email": "kiliman@gmail.com", 6 | "age": 52 7 | }, 8 | { 9 | "id": 2, 10 | "name": "John Doe", 11 | "email": "jdoe@example.com", 12 | "age": 25 13 | }, 14 | { 15 | "id": 3, 16 | "name": "Mary Smith", 17 | "email": "msmith@example.com", 18 | "age": 30 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /error-logging/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { startTransition, StrictMode } from "react"; 3 | import { hydrateRoot } from "react-dom/client"; 4 | 5 | function hydrate() { 6 | startTransition(() => { 7 | hydrateRoot( 8 | document, 9 | 10 | 11 | 12 | ); 13 | }); 14 | } 15 | 16 | if (window.requestIdleCallback) { 17 | window.requestIdleCallback(hydrate); 18 | } else { 19 | // Safari doesn't support requestIdleCallback 20 | // https://caniuse.com/requestidlecallback 21 | window.setTimeout(hydrate, 1); 22 | } 23 | -------------------------------------------------------------------------------- /error-logging/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import Bugsnag from '@bugsnag/js' 2 | import { PassThrough } from 'stream' 3 | import type { EntryContext } from '@remix-run/node' 4 | import { Response } from '@remix-run/node' 5 | import { RemixServer } from '@remix-run/react' 6 | import isbot from 'isbot' 7 | import { renderToPipeableStream } from 'react-dom/server' 8 | 9 | Bugsnag.start({ 10 | apiKey: process.env.BUGSNAG_API_KEY!, 11 | }) 12 | 13 | const ABORT_DELAY = 5000 14 | 15 | export default function handleRequest( 16 | request: Request, 17 | responseStatusCode: number, 18 | responseHeaders: Headers, 19 | remixContext: EntryContext, 20 | ) { 21 | return isbot(request.headers.get('user-agent')) 22 | ? handleBotRequest( 23 | request, 24 | responseStatusCode, 25 | responseHeaders, 26 | remixContext, 27 | ) 28 | : handleBrowserRequest( 29 | request, 30 | responseStatusCode, 31 | responseHeaders, 32 | remixContext, 33 | ) 34 | } 35 | 36 | function handleBotRequest( 37 | request: Request, 38 | responseStatusCode: number, 39 | responseHeaders: Headers, 40 | remixContext: EntryContext, 41 | ) { 42 | return new Promise((resolve, reject) => { 43 | let didError = false 44 | 45 | const { pipe, abort } = renderToPipeableStream( 46 | , 47 | { 48 | onAllReady() { 49 | const body = new PassThrough() 50 | 51 | responseHeaders.set('Content-Type', 'text/html') 52 | 53 | resolve( 54 | new Response(body, { 55 | headers: responseHeaders, 56 | status: didError ? 500 : responseStatusCode, 57 | }), 58 | ) 59 | 60 | pipe(body) 61 | }, 62 | onShellError(error: unknown) { 63 | reject(error) 64 | }, 65 | onError(error: unknown) { 66 | didError = true 67 | 68 | console.error(error) 69 | }, 70 | }, 71 | ) 72 | 73 | setTimeout(abort, ABORT_DELAY) 74 | }) 75 | } 76 | 77 | function handleBrowserRequest( 78 | request: Request, 79 | responseStatusCode: number, 80 | responseHeaders: Headers, 81 | remixContext: EntryContext, 82 | ) { 83 | return new Promise((resolve, reject) => { 84 | let didError = false 85 | 86 | const { pipe, abort } = renderToPipeableStream( 87 | , 88 | { 89 | onShellReady() { 90 | const body = new PassThrough() 91 | 92 | responseHeaders.set('Content-Type', 'text/html') 93 | 94 | resolve( 95 | new Response(body, { 96 | headers: responseHeaders, 97 | status: didError ? 500 : responseStatusCode, 98 | }), 99 | ) 100 | 101 | pipe(body) 102 | }, 103 | onShellError(err: unknown) { 104 | reject(err) 105 | }, 106 | onError(error: unknown) { 107 | didError = true 108 | 109 | console.error(error) 110 | }, 111 | }, 112 | ) 113 | 114 | setTimeout(abort, ABORT_DELAY) 115 | }) 116 | } 117 | 118 | export function handleError(request: Request, error: Error, context: any) { 119 | console.log('notify bugsnag', error) 120 | Bugsnag.notify(error, event => { 121 | event.addMetadata('request', { 122 | url: request.url, 123 | method: request.method, 124 | headers: Object.fromEntries(request.headers.entries()), 125 | }) 126 | }) 127 | } 128 | -------------------------------------------------------------------------------- /error-logging/app/models.server/user.ts: -------------------------------------------------------------------------------- 1 | import usersJson from '~/data/users.json' 2 | declare global { 3 | var __users__: User[] 4 | } 5 | 6 | let users: User[] 7 | 8 | if (!global.__users__) { 9 | global.__users__ = usersJson 10 | } 11 | users = global.__users__ 12 | 13 | export type User = { 14 | id: number 15 | name: string 16 | email: string 17 | age: number 18 | } 19 | 20 | export function getUsers() { 21 | return users as User[] 22 | } 23 | 24 | export function getUser(userId: number) { 25 | return users.find(user => user.id === userId) as User | null 26 | } 27 | 28 | export function saveUser( 29 | userId: number, 30 | { name, email, age }: Omit, 31 | ) { 32 | const user = users.find(user => user.id === userId) as User | null 33 | if (!user) { 34 | return null 35 | } 36 | user.name = name 37 | user.email = email 38 | user.age = age 39 | return user 40 | } 41 | -------------------------------------------------------------------------------- /error-logging/app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from '@remix-run/node' 2 | import { 3 | Links, 4 | LiveReload, 5 | Meta, 6 | NavLink, 7 | Outlet, 8 | Scripts, 9 | ScrollRestoration, 10 | } from '@remix-run/react' 11 | import tailwindCss from '~/styles/tailwind.css' 12 | 13 | export const links = () => [{ rel: 'stylesheet', href: tailwindCss }] 14 | export const meta: MetaFunction = () => ({ 15 | charset: 'utf-8', 16 | title: 'New Remix App', 17 | viewport: 'width=device-width,initial-scale=1', 18 | }) 19 | 20 | export default function App() { 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 33 |
34 | 35 |
36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /error-logging/app/routes/error.tsx: -------------------------------------------------------------------------------- 1 | export const loader = async () => { 2 | throw new Error(`Oops! at ${new Date().toISOString()}`) 3 | } 4 | 5 | export default function () { 6 | return
Oops!
7 | } 8 | -------------------------------------------------------------------------------- /error-logging/app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@remix-run/react' 2 | 3 | export default function Index() { 4 | return ( 5 |
6 | 7 | Throw Error 8 | 9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /error-logging/app/routes/users.$userId_.edit.tsx: -------------------------------------------------------------------------------- 1 | import { type ActionArgs, type LoaderArgs } from '@remix-run/node' 2 | import { Form } from '@remix-run/react' 3 | import { z } from 'zod' 4 | import { getUser, saveUser } from '~/models.server/user' 5 | import { getField, getInvalid } from '~/utils/data' 6 | import { getFormData, getParamsOrThrow } from '~/utils/params' 7 | import { invalid, notFound } from '~/utils/responses' 8 | import { 9 | redirect, 10 | typedjson, 11 | useTypedActionData, 12 | useTypedLoaderData, 13 | } from '~/utils/typedjson' 14 | 15 | export const loader = async ({ params }: LoaderArgs) => { 16 | const { userId } = getParamsOrThrow(params, z.object({ userId: z.number() })) 17 | const user = await getUser(userId) 18 | if (!user) { 19 | throw notFound('User not found') 20 | } 21 | return typedjson({ user }) 22 | } 23 | 24 | const formSchema = z.object({ 25 | name: z.string(), 26 | email: z.string().email(), 27 | age: z.number(), 28 | }) 29 | 30 | export const action = async ({ request, params }: ActionArgs) => { 31 | const { userId } = getParamsOrThrow(params, z.object({ userId: z.number() })) 32 | const [errors, data, fields] = await getFormData(request, formSchema) 33 | if (errors) { 34 | return invalid({ errors, fields }) 35 | } 36 | const { name, email, age } = data 37 | await saveUser(userId, { name, email, age }) 38 | 39 | return redirect('/users/') 40 | } 41 | 42 | export default function Index() { 43 | const { user } = useTypedLoaderData() 44 | const data = useTypedActionData() 45 | const [errors, fields] = getInvalid(data) 46 | 47 | return ( 48 |
49 |
50 |
51 | 52 | 58 | {errors.name &&

{errors.name}

} 59 |
60 |
61 | 62 | 68 | {errors.email &&

{errors.email}

} 69 |
70 |
71 | 72 | 78 | {errors.age &&

{errors.age}

} 79 |
80 | 86 |
87 |
88 | ) 89 | } 90 | 91 | export function CatchBoundary() { 92 | return ( 93 |
94 |

Something went wrong

95 |
96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /error-logging/app/routes/users._index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@remix-run/react' 2 | import { getUsers } from '~/models.server/user' 3 | import { typedjson, useTypedLoaderData } from '~/utils/typedjson' 4 | 5 | export const loader = async () => { 6 | const users = getUsers() 7 | return typedjson({ users }) 8 | } 9 | 10 | export default function UsersList() { 11 | const { users } = useTypedLoaderData() 12 | return ( 13 |
14 |

Users

15 |
    16 | {users.map(user => ( 17 |
  • 21 | 22 |
    {user.name}
    23 |
    {user.email}
    24 |
    Age: {user.age}
    25 | 26 |
  • 27 | ))} 28 |
29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /error-logging/app/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: #e5e7eb; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | 5. Use the user's configured `sans` font-feature-settings by default. 34 | */ 35 | 36 | html { 37 | line-height: 1.5; 38 | /* 1 */ 39 | -webkit-text-size-adjust: 100%; 40 | /* 2 */ 41 | -moz-tab-size: 4; 42 | /* 3 */ 43 | -o-tab-size: 4; 44 | tab-size: 4; 45 | /* 3 */ 46 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 47 | /* 4 */ 48 | font-feature-settings: normal; 49 | /* 5 */ 50 | } 51 | 52 | /* 53 | 1. Remove the margin in all browsers. 54 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 55 | */ 56 | 57 | body { 58 | margin: 0; 59 | /* 1 */ 60 | line-height: inherit; 61 | /* 2 */ 62 | } 63 | 64 | /* 65 | 1. Add the correct height in Firefox. 66 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 67 | 3. Ensure horizontal rules are visible by default. 68 | */ 69 | 70 | hr { 71 | height: 0; 72 | /* 1 */ 73 | color: inherit; 74 | /* 2 */ 75 | border-top-width: 1px; 76 | /* 3 */ 77 | } 78 | 79 | /* 80 | Add the correct text decoration in Chrome, Edge, and Safari. 81 | */ 82 | 83 | abbr:where([title]) { 84 | -webkit-text-decoration: underline dotted; 85 | text-decoration: underline dotted; 86 | } 87 | 88 | /* 89 | Remove the default font size and weight for headings. 90 | */ 91 | 92 | h1, 93 | h2, 94 | h3, 95 | h4, 96 | h5, 97 | h6 { 98 | font-size: inherit; 99 | font-weight: inherit; 100 | } 101 | 102 | /* 103 | Reset links to optimize for opt-in styling instead of opt-out. 104 | */ 105 | 106 | a { 107 | color: inherit; 108 | text-decoration: inherit; 109 | } 110 | 111 | /* 112 | Add the correct font weight in Edge and Safari. 113 | */ 114 | 115 | b, 116 | strong { 117 | font-weight: bolder; 118 | } 119 | 120 | /* 121 | 1. Use the user's configured `mono` font family by default. 122 | 2. Correct the odd `em` font sizing in all browsers. 123 | */ 124 | 125 | code, 126 | kbd, 127 | samp, 128 | pre { 129 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 130 | /* 1 */ 131 | font-size: 1em; 132 | /* 2 */ 133 | } 134 | 135 | /* 136 | Add the correct font size in all browsers. 137 | */ 138 | 139 | small { 140 | font-size: 80%; 141 | } 142 | 143 | /* 144 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 145 | */ 146 | 147 | sub, 148 | sup { 149 | font-size: 75%; 150 | line-height: 0; 151 | position: relative; 152 | vertical-align: baseline; 153 | } 154 | 155 | sub { 156 | bottom: -0.25em; 157 | } 158 | 159 | sup { 160 | top: -0.5em; 161 | } 162 | 163 | /* 164 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 165 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 166 | 3. Remove gaps between table borders by default. 167 | */ 168 | 169 | table { 170 | text-indent: 0; 171 | /* 1 */ 172 | border-color: inherit; 173 | /* 2 */ 174 | border-collapse: collapse; 175 | /* 3 */ 176 | } 177 | 178 | /* 179 | 1. Change the font styles in all browsers. 180 | 2. Remove the margin in Firefox and Safari. 181 | 3. Remove default padding in all browsers. 182 | */ 183 | 184 | button, 185 | input, 186 | optgroup, 187 | select, 188 | textarea { 189 | font-family: inherit; 190 | /* 1 */ 191 | font-size: 100%; 192 | /* 1 */ 193 | font-weight: inherit; 194 | /* 1 */ 195 | line-height: inherit; 196 | /* 1 */ 197 | color: inherit; 198 | /* 1 */ 199 | margin: 0; 200 | /* 2 */ 201 | padding: 0; 202 | /* 3 */ 203 | } 204 | 205 | /* 206 | Remove the inheritance of text transform in Edge and Firefox. 207 | */ 208 | 209 | button, 210 | select { 211 | text-transform: none; 212 | } 213 | 214 | /* 215 | 1. Correct the inability to style clickable types in iOS and Safari. 216 | 2. Remove default button styles. 217 | */ 218 | 219 | button, 220 | [type='button'], 221 | [type='reset'], 222 | [type='submit'] { 223 | -webkit-appearance: button; 224 | /* 1 */ 225 | background-color: transparent; 226 | /* 2 */ 227 | background-image: none; 228 | /* 2 */ 229 | } 230 | 231 | /* 232 | Use the modern Firefox focus style for all focusable elements. 233 | */ 234 | 235 | :-moz-focusring { 236 | outline: auto; 237 | } 238 | 239 | /* 240 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 241 | */ 242 | 243 | :-moz-ui-invalid { 244 | box-shadow: none; 245 | } 246 | 247 | /* 248 | Add the correct vertical alignment in Chrome and Firefox. 249 | */ 250 | 251 | progress { 252 | vertical-align: baseline; 253 | } 254 | 255 | /* 256 | Correct the cursor style of increment and decrement buttons in Safari. 257 | */ 258 | 259 | ::-webkit-inner-spin-button, 260 | ::-webkit-outer-spin-button { 261 | height: auto; 262 | } 263 | 264 | /* 265 | 1. Correct the odd appearance in Chrome and Safari. 266 | 2. Correct the outline style in Safari. 267 | */ 268 | 269 | [type='search'] { 270 | -webkit-appearance: textfield; 271 | /* 1 */ 272 | outline-offset: -2px; 273 | /* 2 */ 274 | } 275 | 276 | /* 277 | Remove the inner padding in Chrome and Safari on macOS. 278 | */ 279 | 280 | ::-webkit-search-decoration { 281 | -webkit-appearance: none; 282 | } 283 | 284 | /* 285 | 1. Correct the inability to style clickable types in iOS and Safari. 286 | 2. Change font properties to `inherit` in Safari. 287 | */ 288 | 289 | ::-webkit-file-upload-button { 290 | -webkit-appearance: button; 291 | /* 1 */ 292 | font: inherit; 293 | /* 2 */ 294 | } 295 | 296 | /* 297 | Add the correct display in Chrome and Safari. 298 | */ 299 | 300 | summary { 301 | display: list-item; 302 | } 303 | 304 | /* 305 | Removes the default spacing and border for appropriate elements. 306 | */ 307 | 308 | blockquote, 309 | dl, 310 | dd, 311 | h1, 312 | h2, 313 | h3, 314 | h4, 315 | h5, 316 | h6, 317 | hr, 318 | figure, 319 | p, 320 | pre { 321 | margin: 0; 322 | } 323 | 324 | fieldset { 325 | margin: 0; 326 | padding: 0; 327 | } 328 | 329 | legend { 330 | padding: 0; 331 | } 332 | 333 | ol, 334 | ul, 335 | menu { 336 | list-style: none; 337 | margin: 0; 338 | padding: 0; 339 | } 340 | 341 | /* 342 | Prevent resizing textareas horizontally by default. 343 | */ 344 | 345 | textarea { 346 | resize: vertical; 347 | } 348 | 349 | /* 350 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 351 | 2. Set the default placeholder color to the user's configured gray 400 color. 352 | */ 353 | 354 | input::-moz-placeholder, textarea::-moz-placeholder { 355 | opacity: 1; 356 | /* 1 */ 357 | color: #9ca3af; 358 | /* 2 */ 359 | } 360 | 361 | input::placeholder, 362 | textarea::placeholder { 363 | opacity: 1; 364 | /* 1 */ 365 | color: #9ca3af; 366 | /* 2 */ 367 | } 368 | 369 | /* 370 | Set the default cursor for buttons. 371 | */ 372 | 373 | button, 374 | [role="button"] { 375 | cursor: pointer; 376 | } 377 | 378 | /* 379 | Make sure disabled buttons don't get the pointer cursor. 380 | */ 381 | 382 | :disabled { 383 | cursor: default; 384 | } 385 | 386 | /* 387 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 388 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 389 | This can trigger a poorly considered lint error in some tools but is included by design. 390 | */ 391 | 392 | img, 393 | svg, 394 | video, 395 | canvas, 396 | audio, 397 | iframe, 398 | embed, 399 | object { 400 | display: block; 401 | /* 1 */ 402 | vertical-align: middle; 403 | /* 2 */ 404 | } 405 | 406 | /* 407 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 408 | */ 409 | 410 | img, 411 | video { 412 | max-width: 100%; 413 | height: auto; 414 | } 415 | 416 | /* Make elements with the HTML hidden attribute stay hidden by default */ 417 | 418 | [hidden] { 419 | display: none; 420 | } 421 | 422 | *, ::before, ::after { 423 | --tw-border-spacing-x: 0; 424 | --tw-border-spacing-y: 0; 425 | --tw-translate-x: 0; 426 | --tw-translate-y: 0; 427 | --tw-rotate: 0; 428 | --tw-skew-x: 0; 429 | --tw-skew-y: 0; 430 | --tw-scale-x: 1; 431 | --tw-scale-y: 1; 432 | --tw-pan-x: ; 433 | --tw-pan-y: ; 434 | --tw-pinch-zoom: ; 435 | --tw-scroll-snap-strictness: proximity; 436 | --tw-ordinal: ; 437 | --tw-slashed-zero: ; 438 | --tw-numeric-figure: ; 439 | --tw-numeric-spacing: ; 440 | --tw-numeric-fraction: ; 441 | --tw-ring-inset: ; 442 | --tw-ring-offset-width: 0px; 443 | --tw-ring-offset-color: #fff; 444 | --tw-ring-color: rgb(59 130 246 / 0.5); 445 | --tw-ring-offset-shadow: 0 0 #0000; 446 | --tw-ring-shadow: 0 0 #0000; 447 | --tw-shadow: 0 0 #0000; 448 | --tw-shadow-colored: 0 0 #0000; 449 | --tw-blur: ; 450 | --tw-brightness: ; 451 | --tw-contrast: ; 452 | --tw-grayscale: ; 453 | --tw-hue-rotate: ; 454 | --tw-invert: ; 455 | --tw-saturate: ; 456 | --tw-sepia: ; 457 | --tw-drop-shadow: ; 458 | --tw-backdrop-blur: ; 459 | --tw-backdrop-brightness: ; 460 | --tw-backdrop-contrast: ; 461 | --tw-backdrop-grayscale: ; 462 | --tw-backdrop-hue-rotate: ; 463 | --tw-backdrop-invert: ; 464 | --tw-backdrop-opacity: ; 465 | --tw-backdrop-saturate: ; 466 | --tw-backdrop-sepia: ; 467 | } 468 | 469 | ::backdrop { 470 | --tw-border-spacing-x: 0; 471 | --tw-border-spacing-y: 0; 472 | --tw-translate-x: 0; 473 | --tw-translate-y: 0; 474 | --tw-rotate: 0; 475 | --tw-skew-x: 0; 476 | --tw-skew-y: 0; 477 | --tw-scale-x: 1; 478 | --tw-scale-y: 1; 479 | --tw-pan-x: ; 480 | --tw-pan-y: ; 481 | --tw-pinch-zoom: ; 482 | --tw-scroll-snap-strictness: proximity; 483 | --tw-ordinal: ; 484 | --tw-slashed-zero: ; 485 | --tw-numeric-figure: ; 486 | --tw-numeric-spacing: ; 487 | --tw-numeric-fraction: ; 488 | --tw-ring-inset: ; 489 | --tw-ring-offset-width: 0px; 490 | --tw-ring-offset-color: #fff; 491 | --tw-ring-color: rgb(59 130 246 / 0.5); 492 | --tw-ring-offset-shadow: 0 0 #0000; 493 | --tw-ring-shadow: 0 0 #0000; 494 | --tw-shadow: 0 0 #0000; 495 | --tw-shadow-colored: 0 0 #0000; 496 | --tw-blur: ; 497 | --tw-brightness: ; 498 | --tw-contrast: ; 499 | --tw-grayscale: ; 500 | --tw-hue-rotate: ; 501 | --tw-invert: ; 502 | --tw-saturate: ; 503 | --tw-sepia: ; 504 | --tw-drop-shadow: ; 505 | --tw-backdrop-blur: ; 506 | --tw-backdrop-brightness: ; 507 | --tw-backdrop-contrast: ; 508 | --tw-backdrop-grayscale: ; 509 | --tw-backdrop-hue-rotate: ; 510 | --tw-backdrop-invert: ; 511 | --tw-backdrop-opacity: ; 512 | --tw-backdrop-saturate: ; 513 | --tw-backdrop-sepia: ; 514 | } 515 | 516 | .m-4 { 517 | margin: 1rem; 518 | } 519 | 520 | .mt-1 { 521 | margin-top: 0.25rem; 522 | } 523 | 524 | .mt-4 { 525 | margin-top: 1rem; 526 | } 527 | 528 | .mb-4 { 529 | margin-bottom: 1rem; 530 | } 531 | 532 | .block { 533 | display: block; 534 | } 535 | 536 | .flex { 537 | display: flex; 538 | } 539 | 540 | .max-w-md { 541 | max-width: 28rem; 542 | } 543 | 544 | .flex-col { 545 | flex-direction: column; 546 | } 547 | 548 | .items-start { 549 | align-items: flex-start; 550 | } 551 | 552 | .gap-4 { 553 | gap: 1rem; 554 | } 555 | 556 | .rounded { 557 | border-radius: 0.25rem; 558 | } 559 | 560 | .border { 561 | border-width: 1px; 562 | } 563 | 564 | .border-b { 565 | border-bottom-width: 1px; 566 | } 567 | 568 | .bg-red-500 { 569 | --tw-bg-opacity: 1; 570 | background-color: rgb(239 68 68 / var(--tw-bg-opacity)); 571 | } 572 | 573 | .bg-blue-500 { 574 | --tw-bg-opacity: 1; 575 | background-color: rgb(59 130 246 / var(--tw-bg-opacity)); 576 | } 577 | 578 | .p-4 { 579 | padding: 1rem; 580 | } 581 | 582 | .px-2 { 583 | padding-left: 0.5rem; 584 | padding-right: 0.5rem; 585 | } 586 | 587 | .py-1 { 588 | padding-top: 0.25rem; 589 | padding-bottom: 0.25rem; 590 | } 591 | 592 | .text-2xl { 593 | font-size: 1.5rem; 594 | line-height: 2rem; 595 | } 596 | 597 | .font-semibold { 598 | font-weight: 600; 599 | } 600 | 601 | .text-white { 602 | --tw-text-opacity: 1; 603 | color: rgb(255 255 255 / var(--tw-text-opacity)); 604 | } 605 | 606 | .text-red-700 { 607 | --tw-text-opacity: 1; 608 | color: rgb(185 28 28 / var(--tw-text-opacity)); 609 | } 610 | 611 | .shadow-md { 612 | --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 613 | --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); 614 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 615 | } 616 | 617 | .hover\:bg-blue-100:hover { 618 | --tw-bg-opacity: 1; 619 | background-color: rgb(219 234 254 / var(--tw-bg-opacity)); 620 | } -------------------------------------------------------------------------------- /error-logging/app/utils/data.ts: -------------------------------------------------------------------------------- 1 | export type ErrorsType = { [key: string]: string } 2 | export type FieldsType = { [key: string]: string } 3 | 4 | export type ActionData = 5 | | { errors: ErrorsType; fields: FieldsType } 6 | | { success: SuccessType } 7 | 8 | export function getInvalid( 9 | data: ActionData | null, 10 | ) { 11 | return [getErrors(data), getFields(data)] 12 | } 13 | 14 | export function getErrors( 15 | data: ActionData | null, 16 | ) { 17 | if (data && 'errors' in data) { 18 | return data['errors'] as ErrorsType 19 | } 20 | return {} as ErrorsType 21 | } 22 | export function getFields( 23 | data: ActionData | null, 24 | ) { 25 | if (data && 'fields' in data) { 26 | return data['fields'] as FieldsType 27 | } 28 | return {} as FieldsType 29 | } 30 | 31 | export function getSuccess( 32 | data: ActionData | null, 33 | ) { 34 | if (data && 'success' in data) { 35 | return data['success'] as SuccessType 36 | } 37 | return {} as SuccessType 38 | } 39 | 40 | export function getField( 41 | data: T, 42 | fields: { [key: string]: string }, 43 | name: keyof T, 44 | ) { 45 | return (fields[name as string] as string) ?? data[name] 46 | } 47 | -------------------------------------------------------------------------------- /error-logging/app/utils/params.ts: -------------------------------------------------------------------------------- 1 | import type { z, ZodType, ZodTypeAny } from 'zod' 2 | import { 3 | ZodArray, 4 | ZodBoolean, 5 | ZodDate, 6 | ZodDefault, 7 | ZodEffects, 8 | ZodEnum, 9 | ZodLiteral, 10 | ZodNativeEnum, 11 | ZodNumber, 12 | ZodObject, 13 | ZodOptional, 14 | ZodString, 15 | } from 'zod' 16 | 17 | function isIterable( 18 | maybeIterable: unknown, 19 | ): maybeIterable is Iterable { 20 | return Symbol.iterator in Object(maybeIterable) 21 | } 22 | 23 | function parseParams(o: any, schema: any, key: string, value: any) { 24 | // find actual shape definition for this key 25 | let shape = schema 26 | while (shape instanceof ZodObject || shape instanceof ZodEffects) { 27 | shape = 28 | shape instanceof ZodObject 29 | ? shape.shape 30 | : shape instanceof ZodEffects 31 | ? shape._def.schema 32 | : null 33 | if (shape === null) { 34 | throw new Error(`Could not find shape for key ${key}`) 35 | } 36 | } 37 | 38 | if (key.includes('.')) { 39 | let [parentProp, ...rest] = key.split('.') 40 | o[parentProp] = o[parentProp] ?? {} 41 | parseParams(o[parentProp], shape[parentProp], rest.join('.'), value) 42 | return 43 | } 44 | let isArray = false 45 | if (key.includes('[]')) { 46 | isArray = true 47 | key = key.replace('[]', '') 48 | } 49 | const def = shape[key] 50 | if (def) { 51 | processDef(def, o, key, value as string) 52 | } 53 | } 54 | 55 | function getParamsInternal( 56 | params: URLSearchParams | FormData | Record, 57 | schema: any, 58 | ): 59 | | [{ [key: string]: string }, T, { [key: string]: string }] 60 | | [null, T, { [key: string]: string }] { 61 | // [errors, data] 62 | // @ts-ignore 63 | let o: any = {} 64 | let entries: [string, unknown][] = [] 65 | if (isIterable(params)) { 66 | entries = Array.from(params) 67 | } else { 68 | entries = Object.entries(params) 69 | } 70 | let fields: { [key: string]: string } = {} 71 | for (let [key, value] of entries) { 72 | fields[key] = String(value) 73 | // infer an empty param as if it wasn't defined in the first place 74 | if (value === '') { 75 | continue 76 | } 77 | parseParams(o, schema, key, value) 78 | } 79 | 80 | const result = schema.safeParse(o) 81 | if (result.success) { 82 | return [null, result.data as T, fields] 83 | } else { 84 | let errors: any = {} 85 | const addError = (key: string, message: string) => { 86 | if (!errors.hasOwnProperty(key)) { 87 | errors[key] = message 88 | } else { 89 | if (!Array.isArray(errors[key])) { 90 | errors[key] = [errors[key]] 91 | } 92 | errors[key].push(message) 93 | } 94 | } 95 | for (let issue of result.error.issues) { 96 | const { message, path, code, expected, received } = issue 97 | const [key, index] = path 98 | let value = o[key] 99 | let prop = key 100 | if (index !== undefined) { 101 | value = value[index] 102 | prop = `${key}[${index}]` 103 | } 104 | addError(key, message) 105 | } 106 | return [errors, null, fields] 107 | } 108 | } 109 | 110 | export function getParams>( 111 | params: URLSearchParams | FormData | Record, 112 | schema: T, 113 | ) { 114 | type ParamsType = z.infer 115 | return getParamsInternal(params, schema) 116 | } 117 | 118 | export function getSearchParams>( 119 | request: Pick, 120 | schema: T, 121 | ) { 122 | type ParamsType = z.infer 123 | let url = new URL(request.url) 124 | return getParamsInternal(url.searchParams, schema) 125 | } 126 | 127 | export async function getFormData>( 128 | request: Pick, 129 | schema: T, 130 | ) { 131 | type ParamsType = z.infer 132 | let data = await request.formData() 133 | return getParamsInternal(data, schema) 134 | } 135 | 136 | export function getParamsOrFail>( 137 | params: URLSearchParams | FormData | Record, 138 | schema: T, 139 | ) { 140 | type ParamsType = z.infer 141 | const result = getParamsInternal(params, schema) 142 | if (!result.success) { 143 | throw new Error(JSON.stringify(result.errors)) 144 | } 145 | return result.data 146 | } 147 | export function getParamsOrThrow>( 148 | params: URLSearchParams | FormData | Record, 149 | schema: T, 150 | ) { 151 | type ParamsType = z.infer 152 | const [errors, data] = getParamsInternal(params, schema) 153 | if (errors) { 154 | throw new Response('Bad Request', { status: 400 }) 155 | } 156 | return data 157 | } 158 | 159 | export function getSearchParamsOrFail>( 160 | request: Pick, 161 | schema: T, 162 | ) { 163 | type ParamsType = z.infer 164 | let url = new URL(request.url) 165 | const result = getParamsInternal(url.searchParams, schema) 166 | if (!result.success) { 167 | throw new Error(JSON.stringify(result.errors)) 168 | } 169 | return result.data 170 | } 171 | export function getSearchParamsOrThrow>( 172 | request: Pick, 173 | schema: T, 174 | ) { 175 | type ParamsType = z.infer 176 | let url = new URL(request.url) 177 | const [errors, data] = getParamsInternal(url.searchParams, schema) 178 | if (errors) { 179 | throw new Response('Bad Request', { status: 400 }) 180 | } 181 | return data 182 | } 183 | 184 | export async function getFormDataOrFail>( 185 | request: Pick, 186 | schema: T, 187 | ) { 188 | type ParamsType = z.infer 189 | let data = await request.formData() 190 | const result = getParamsInternal(data, schema) 191 | if (!result.success) { 192 | throw new Error(JSON.stringify(result.errors)) 193 | } 194 | return result.data 195 | } 196 | 197 | export type InputPropType = { 198 | name: string 199 | type: string 200 | required?: boolean 201 | min?: number 202 | max?: number 203 | minLength?: number 204 | maxLength?: number 205 | pattern?: string 206 | } 207 | 208 | export function useFormInputProps(schema: any, options: any = {}) { 209 | const shape = schema.shape 210 | const defaultOptions = options 211 | return function props(key: string, options: any = {}) { 212 | options = { ...defaultOptions, ...options } 213 | const def = shape[key] 214 | if (!def) { 215 | throw new Error(`no such key: ${key}`) 216 | } 217 | return getInputProps(key, def) 218 | } 219 | } 220 | 221 | function processDef(def: ZodTypeAny, o: any, key: string, value: string) { 222 | let parsedValue: any 223 | if (def instanceof ZodString || def instanceof ZodLiteral) { 224 | parsedValue = value 225 | } else if (def instanceof ZodNumber) { 226 | const num = Number(value) 227 | parsedValue = isNaN(num) ? value : num 228 | } else if (def instanceof ZodDate) { 229 | const date = Date.parse(value) 230 | parsedValue = isNaN(date) ? value : new Date(date) 231 | } else if (def instanceof ZodBoolean) { 232 | parsedValue = 233 | value === 'true' ? true : value === 'false' ? false : Boolean(value) 234 | } else if (def instanceof ZodNativeEnum || def instanceof ZodEnum) { 235 | parsedValue = value 236 | } else if (def instanceof ZodOptional || def instanceof ZodDefault) { 237 | // def._def.innerType is the same as ZodOptional's .unwrap(), which unfortunately doesn't exist on ZodDefault 238 | processDef(def._def.innerType, o, key, value) 239 | // return here to prevent overwriting the result of the recursive call 240 | return 241 | } else if (def instanceof ZodArray) { 242 | if (o[key] === undefined) { 243 | o[key] = [] 244 | } 245 | processDef(def.element, o, key, value) 246 | // return here since recursive call will add to array 247 | return 248 | } else if (def instanceof ZodEffects) { 249 | processDef(def._def.schema, o, key, value) 250 | return 251 | } else { 252 | throw new Error(`Unexpected type ${def._def.typeName} for key ${key}`) 253 | } 254 | if (Array.isArray(o[key])) { 255 | o[key].push(parsedValue) 256 | } else { 257 | o[key] = parsedValue 258 | } 259 | } 260 | 261 | function getInputProps(name: string, def: ZodTypeAny): InputPropType { 262 | let type = 'text' 263 | let min, max, minlength, maxlength, pattern 264 | if (def instanceof ZodString) { 265 | if (def.isEmail) { 266 | type = 'email' 267 | } else if (def.isURL) { 268 | type = 'url' 269 | } 270 | minlength = def.minLength ?? undefined 271 | maxlength = def.maxLength ?? undefined 272 | const check: any = def._def.checks.find(c => c.kind === 'regex') 273 | pattern = check ? check.regex.source : undefined 274 | } else if (def instanceof ZodNumber) { 275 | type = 'number' 276 | min = def.minValue ?? undefined 277 | max = def.maxValue ?? undefined 278 | } else if (def instanceof ZodBoolean) { 279 | type = 'checkbox' 280 | } else if (def instanceof ZodDate) { 281 | type = 'date' 282 | } else if (def instanceof ZodArray) { 283 | return getInputProps(name, def.element) 284 | } else if (def instanceof ZodOptional) { 285 | return getInputProps(name, def.unwrap()) 286 | } 287 | 288 | let inputProps: InputPropType = { 289 | name, 290 | type, 291 | } 292 | if (!def.isOptional()) inputProps.required = true 293 | if (min) inputProps.min = min 294 | if (max) inputProps.max = max 295 | if (minlength && Number.isFinite(minlength)) inputProps.minLength = minlength 296 | if (maxlength && Number.isFinite(maxlength)) inputProps.maxLength = maxlength 297 | if (pattern) inputProps.pattern = pattern 298 | return inputProps 299 | } 300 | -------------------------------------------------------------------------------- /error-logging/app/utils/responses.ts: -------------------------------------------------------------------------------- 1 | import { type ActionData } from '~/utils/data' 2 | import { typedjson } from './typedjson' 3 | 4 | export function notFound(message?: string) { 5 | return new Response(message ?? 'Not Found', { status: 404 }) 6 | } 7 | 8 | export function badRequest(message?: string) { 9 | return new Response(message ?? 'Bad Request', { status: 400 }) 10 | } 11 | 12 | export function unauthorized(message?: string) { 13 | return new Response(message ?? 'Unauthorized', { status: 401 }) 14 | } 15 | 16 | export function forbidden(message?: string) { 17 | return new Response(message ?? 'Unauthorized', { status: 403 }) 18 | } 19 | 20 | export function invalid(data: T) { 21 | return typedjson(data, { status: 400 }) 22 | } 23 | export function success( 24 | data: SuccessType, 25 | init?: number | ResponseInit, 26 | ) { 27 | return typedjson( 28 | { success: data } as ActionData, 29 | init ?? { status: 200 }, 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /error-logging/app/utils/serialize.ts: -------------------------------------------------------------------------------- 1 | import { type AppData, type TypedResponse } from '@remix-run/node' 2 | 3 | type JsonPrimitive = 4 | | string 5 | | number 6 | | boolean 7 | | String 8 | | Number 9 | | Boolean 10 | | null 11 | type NonJsonPrimitive = undefined | Function | symbol 12 | 13 | /* 14 | * `any` is the only type that can let you equate `0` with `1` 15 | * See https://stackoverflow.com/a/49928360/1490091 16 | */ 17 | type IsAny = 0 extends 1 & T ? true : false 18 | 19 | // prettier-ignore 20 | type Serialize = 21 | IsAny extends true ? any : 22 | T extends JsonPrimitive ? T : 23 | T extends NonJsonPrimitive ? never : 24 | T extends { toJSON(): infer U } ? U : 25 | T extends [] ? [] : 26 | T extends [unknown, ...unknown[]] ? SerializeTuple : 27 | T extends ReadonlyArray ? (U extends NonJsonPrimitive ? null : Serialize)[] : 28 | T extends object ? SerializeObject> : 29 | never; 30 | 31 | /** JSON serialize [tuples](https://www.typescriptlang.org/docs/handbook/2/objects.html#tuple-types) */ 32 | type SerializeTuple = { 33 | [k in keyof T]: T[k] extends NonJsonPrimitive ? null : Serialize 34 | } 35 | 36 | /** JSON serialize objects (not including arrays) and classes */ 37 | type SerializeObject = { 38 | [k in keyof T as T[k] extends NonJsonPrimitive ? never : k]: Serialize 39 | } 40 | 41 | /* 42 | * For an object T, if it has any properties that are a union with `undefined`, 43 | * make those into optional properties instead. 44 | * 45 | * Example: { a: string | undefined} --> { a?: string} 46 | */ 47 | type UndefinedToOptional = { 48 | // Property is not a union with `undefined`, keep as-is 49 | [k in keyof T as undefined extends T[k] ? never : k]: T[k] 50 | } & { 51 | // Property _is_ a union with `defined`. Set as optional (via `?`) and remove `undefined` from the union 52 | [k in keyof T as undefined extends T[k] ? k : never]?: Exclude< 53 | T[k], 54 | undefined 55 | > 56 | } 57 | 58 | type ArbitraryFunction = (...args: any[]) => unknown 59 | 60 | /** 61 | * Infer JSON serialized data type returned by a loader or action. 62 | * 63 | * For example: 64 | * `type LoaderData = SerializeFrom` 65 | */ 66 | export type SerializeFrom = Serialize< 67 | T extends (...args: any[]) => infer Output 68 | ? Awaited extends TypedResponse 69 | ? U 70 | : Awaited 71 | : Awaited 72 | > 73 | 74 | export type UnwrapSerializeObject = T extends SerializeObject 75 | ? U 76 | : never 77 | -------------------------------------------------------------------------------- /error-logging/app/utils/typedjson/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | deserializeRemix, 3 | redirect, 4 | stringifyRemix, 5 | typedjson, 6 | useTypedActionData, 7 | useTypedFetcher, 8 | useTypedLoaderData, 9 | } from './remix' 10 | export type { 11 | TypedJsonResponse, 12 | TypedMetaFunction, 13 | TypedFetcherWithComponents, 14 | } from './remix' 15 | export { 16 | applyMeta, 17 | deserialize, 18 | parse, 19 | serialize, 20 | stringify, 21 | } from './typedjson' 22 | export type { MetaType, TypedJsonResult } from './typedjson' 23 | -------------------------------------------------------------------------------- /error-logging/app/utils/typedjson/remix.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useActionData, 3 | useFetcher, 4 | useLoaderData, 5 | type FetcherWithComponents, 6 | type HtmlMetaDescriptor, 7 | type Params, 8 | } from '@remix-run/react' 9 | 10 | import type { MetaType } from './typedjson' 11 | import * as _typedjson from './typedjson' 12 | 13 | export type TypedJsonFunction = ( 14 | data: Data, 15 | init?: number | ResponseInit, 16 | ) => TypedJsonResponse 17 | 18 | export declare type TypedJsonResponse = 19 | Response & { 20 | typedjson(): Promise 21 | invalid(): Promise 22 | success(): Promise 23 | } 24 | export interface AppLoadContext { 25 | [key: string]: unknown 26 | } 27 | type AppData = any 28 | type DataFunction = (...args: any[]) => unknown // matches any function 29 | type DataOrFunction = AppData | DataFunction 30 | export interface DataFunctionArgs { 31 | request: Request 32 | context: AppLoadContext 33 | params: Params 34 | } 35 | export type UseDataFunctionReturn = T extends ( 36 | ...args: any[] 37 | ) => infer Output 38 | ? Awaited extends TypedJsonResponse 39 | ? U 40 | : Awaited> 41 | : Awaited 42 | 43 | export const typedjson: TypedJsonFunction = (data, init = {}) => { 44 | let responseInit = typeof init === 'number' ? { status: init } : init 45 | let headers = new Headers(responseInit.headers) 46 | if (!headers.has('Content-Type')) { 47 | headers.set('Content-Type', 'application/json; charset=utf-8') 48 | } 49 | return new Response(stringifyRemix(data), { 50 | ...responseInit, 51 | headers, 52 | }) as TypedJsonResponse 53 | } 54 | 55 | export function useTypedLoaderData(): UseDataFunctionReturn { 56 | const data = useLoaderData() 57 | return deserializeRemix( 58 | data as RemixSerializedType, 59 | ) as UseDataFunctionReturn 60 | } 61 | export function useTypedActionData< 62 | T = AppData, 63 | >(): UseDataFunctionReturn | null { 64 | const data = useActionData() 65 | return deserializeRemix( 66 | data as RemixSerializedType, 67 | ) as UseDataFunctionReturn | null 68 | } 69 | 70 | export type TypedFetcherWithComponents = Omit< 71 | FetcherWithComponents, 72 | 'data' 73 | > & { 74 | data: UseDataFunctionReturn 75 | } 76 | export function useTypedFetcher(): TypedFetcherWithComponents { 77 | const fetcher = useFetcher() 78 | if (fetcher.data) { 79 | const newData = deserializeRemix(fetcher.data as RemixSerializedType) 80 | fetcher.data = newData ?? undefined 81 | } 82 | return fetcher as TypedFetcherWithComponents 83 | } 84 | 85 | type RemixSerializedType = { 86 | __obj__: T | null 87 | __meta__?: MetaType | null 88 | } & (T | { __meta__?: MetaType }) 89 | 90 | export function stringifyRemix(data: T) { 91 | // prevent double JSON stringification 92 | let { json, meta } = _typedjson.serialize(data) 93 | if (json && meta) { 94 | if (json.startsWith('{')) { 95 | json = `${json.substring( 96 | 0, 97 | json.length - 1, 98 | )},\"__meta__\":${JSON.stringify(meta)}}` 99 | } else if (json.startsWith('[')) { 100 | json = `{"__obj__":${json},"__meta__":${JSON.stringify(meta)}}` 101 | } 102 | } 103 | return json 104 | } 105 | 106 | export function deserializeRemix(data: RemixSerializedType): T | null { 107 | if (!data) return data 108 | if (data.__obj__) { 109 | // handle arrays wrapped in an object 110 | return data.__meta__ 111 | ? _typedjson.applyMeta(data.__obj__, data.__meta__) 112 | : data.__obj__ 113 | } else if (data.__meta__) { 114 | // handle object with __meta__ key 115 | // remove before applying meta 116 | const meta = data.__meta__ 117 | delete data.__meta__ 118 | return _typedjson.applyMeta(data as T, meta) 119 | } 120 | return data as T 121 | } 122 | 123 | export type RedirectFunction = ( 124 | url: string, 125 | init?: number | ResponseInit, 126 | ) => TypedJsonResponse 127 | 128 | /** 129 | * A redirect response. Sets the status code and the `Location` header. 130 | * Defaults to "302 Found". 131 | * 132 | * @see https://remix.run/api/remix#redirect 133 | */ 134 | export const redirect: RedirectFunction = (url, init = 302) => { 135 | let responseInit = init 136 | if (typeof responseInit === 'number') { 137 | responseInit = { status: responseInit } 138 | } else if (typeof responseInit.status === 'undefined') { 139 | responseInit.status = 302 140 | } 141 | 142 | let headers = new Headers(responseInit.headers) 143 | headers.set('Location', url) 144 | 145 | return new Response(null, { 146 | ...responseInit, 147 | headers, 148 | }) as TypedJsonResponse 149 | } 150 | export interface RouteData { 151 | [routeId: string]: AppData 152 | } 153 | 154 | export interface LoaderFunction { 155 | (args: DataFunctionArgs): 156 | | Promise 157 | | Response 158 | | Promise 159 | | AppData 160 | } 161 | export interface TypedMetaFunction< 162 | Loader extends LoaderFunction | unknown = unknown, 163 | ParentsLoaders extends Record = {}, 164 | > { 165 | (args: { 166 | data: Loader extends LoaderFunction 167 | ? UseDataFunctionReturn 168 | : AppData 169 | parentsData: { 170 | [k in keyof ParentsLoaders]: UseDataFunctionReturn 171 | } & RouteData 172 | params: Params 173 | location: Location 174 | }): HtmlMetaDescriptor 175 | } 176 | -------------------------------------------------------------------------------- /error-logging/app/utils/typedjson/typedjson.ts: -------------------------------------------------------------------------------- 1 | type NonJsonTypes = 2 | | 'date' 3 | | 'set' 4 | | 'map' 5 | | 'regexp' 6 | | 'bigint' 7 | | 'undefined' 8 | | 'infinity' 9 | | '-infinity' 10 | | 'nan' 11 | | 'error' 12 | type MetaType = Record 13 | type EntryType = { 14 | type: NonJsonTypes | 'object' 15 | value: any 16 | count: number 17 | iteration: number 18 | } 19 | function serialize(data: T): TypedJsonResult { 20 | if (data === null) return { json: 'null' } 21 | if (data === undefined) return { json: undefined } 22 | 23 | const stack: EntryType[] = [] 24 | const keys: string[] = [''] 25 | const meta = new Map() 26 | function replacer(key: string, value: any) { 27 | function unwindStack() { 28 | while (stack.length > 0) { 29 | const top = stack[stack.length - 1] 30 | if (top.iteration < top.count) { 31 | top.iteration++ 32 | return top 33 | } 34 | if (top.type === 'object') { 35 | keys.pop() 36 | } 37 | stack.pop() 38 | } 39 | } 40 | let entry = unwindStack() 41 | if (entry) { 42 | value = entry.value[key] 43 | } 44 | let metaKey = `${keys[keys.length - 1]}${key}` 45 | const valueType = typeof value 46 | if (valueType === 'object' && value !== null) { 47 | let count = 0 48 | let t: NonJsonTypes | 'object' = 'undefined' 49 | if (value instanceof Date) { 50 | t = 'date' 51 | value = value.toISOString() 52 | } else if (value instanceof Set) { 53 | value = Array.from(value) 54 | count = value.length 55 | t = 'set' 56 | } else if (value instanceof Map) { 57 | value = Object.fromEntries(value) 58 | count = Object.keys(value).length 59 | t = 'map' 60 | } else if (value instanceof Array) { 61 | t = 'object' 62 | count = value.length 63 | } else if (value instanceof RegExp) { 64 | t = 'regexp' 65 | value = String(value) 66 | } else if (value instanceof Error) { 67 | t = 'error' 68 | value = { name: value.name, message: value.message, stack: value.stack } 69 | // push error value to stack 70 | stack.push({ type: 'object', value, count: 3, iteration: 0 }) 71 | } else { 72 | count = Object.keys(value).length 73 | t = 'object' 74 | } 75 | if (t !== 'object') { 76 | meta.set(metaKey, t) 77 | } 78 | if (count !== 0) { 79 | stack.push({ type: t, value, count, iteration: 0 }) 80 | if (key && t === 'object') { 81 | keys.push(`${metaKey}.`) 82 | } 83 | return value 84 | } 85 | } 86 | // handle non-object types 87 | if (valueType === 'bigint') { 88 | meta.set(metaKey, 'bigint') 89 | return String(value) 90 | } 91 | if (valueType === 'number') { 92 | if (value === Number.POSITIVE_INFINITY) { 93 | meta.set(metaKey, 'infinity') 94 | return 'Infinity' 95 | } 96 | if (value === Number.NEGATIVE_INFINITY) { 97 | meta.set(metaKey, '-infinity') 98 | return '-Infinity' 99 | } 100 | if (Number.isNaN(value)) { 101 | meta.set(metaKey, 'nan') 102 | return 'NaN' 103 | } 104 | } 105 | if (typeof value === 'undefined') { 106 | meta.set(metaKey, 'undefined') 107 | return null 108 | } 109 | return value 110 | } 111 | const json = JSON.stringify(data, replacer) 112 | return { 113 | json, 114 | meta: meta.size === 0 ? undefined : Object.fromEntries(meta.entries()), 115 | } 116 | } 117 | 118 | function deserialize({ json, meta }: TypedJsonResult): T | null { 119 | if (typeof json === 'undefined') { 120 | return undefined as unknown as T 121 | } 122 | if (!json) return null 123 | const result = JSON.parse(json) 124 | if (meta) { 125 | applyMeta(result, meta) 126 | } 127 | return result as T 128 | } 129 | 130 | function applyMeta(data: T, meta: MetaType) { 131 | for (const key of Object.keys(meta)) { 132 | applyConversion(data, key.split('.'), meta[key]) 133 | } 134 | return data 135 | 136 | function applyConversion( 137 | data: any, 138 | keys: string[], 139 | type: NonJsonTypes, 140 | depth: number = 0, 141 | ) { 142 | const key = keys[depth] 143 | if (depth < keys.length - 1) { 144 | applyConversion(data[key], keys, type, depth + 1) 145 | return 146 | } 147 | const value = data[key] 148 | switch (type) { 149 | case 'date': 150 | data[key] = new Date(value) 151 | break 152 | case 'set': 153 | data[key] = new Set(value) 154 | break 155 | case 'map': 156 | data[key] = new Map(Object.entries(value)) 157 | break 158 | case 'regexp': 159 | const match = /^\/(.*)\/([dgimsuy]*)$/.exec(value) 160 | if (match) { 161 | data[key] = new RegExp(match[1], match[2]) 162 | } else { 163 | throw new Error(`Invalid regexp: ${value}`) 164 | } 165 | break 166 | case 'bigint': 167 | data[key] = BigInt(value) 168 | break 169 | case 'undefined': 170 | data[key] = undefined 171 | break 172 | case 'infinity': 173 | data[key] = Number.POSITIVE_INFINITY 174 | break 175 | case '-infinity': 176 | data[key] = Number.NEGATIVE_INFINITY 177 | break 178 | case 'nan': 179 | data[key] = NaN 180 | break 181 | case 'error': 182 | const err = new Error(value.message) 183 | err.name = value.name 184 | err.stack = value.stack 185 | data[key] = err 186 | break 187 | } 188 | } 189 | } 190 | 191 | type TypedJsonResult = { 192 | json?: string | null 193 | meta?: MetaType 194 | } 195 | type StringifyParameters = Parameters 196 | function stringify( 197 | data: T, 198 | replacer?: StringifyParameters[1], 199 | space?: StringifyParameters[2], 200 | ) { 201 | if (replacer || space) { 202 | const { json, meta } = serialize(data) 203 | const jsonObj = deserialize({ json }) 204 | return JSON.stringify( 205 | { 206 | json: jsonObj, 207 | meta, 208 | }, 209 | replacer, 210 | space, 211 | ) 212 | } 213 | return JSON.stringify(serialize(data)) 214 | } 215 | 216 | function parse(json: string) { 217 | const result: TypedJsonResult | null = JSON.parse(json) 218 | return result ? deserialize(result) : null 219 | } 220 | 221 | const typedjson = { 222 | serialize, 223 | stringify, 224 | deserialize, 225 | parse, 226 | applyMeta, 227 | } 228 | 229 | export { serialize, deserialize, stringify, parse, applyMeta } 230 | export type { MetaType, TypedJsonResult } 231 | export default typedjson 232 | -------------------------------------------------------------------------------- /error-logging/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "sideEffects": false, 4 | "scripts": { 5 | "postinstall": "patch-package", 6 | "clean": "rimraf build public/build", 7 | "prebuild": "npm run clean", 8 | "build": "remix build --sourcemap", 9 | "predev": "npm run clean && npm run css:generate", 10 | "dev": "run-p dev:*", 11 | "dev:remix": "remix dev", 12 | "dev:tailwind": "npm run css:generate -- --watch", 13 | "css:generate": "tailwindcss -o ./app/styles/tailwind.css", 14 | "start": "dotenv remix-serve build" 15 | }, 16 | "dependencies": { 17 | "@bugsnag/js": "^7.18.0", 18 | "@bugsnag/plugin-express": "^7.18.0", 19 | "@remix-run/node": "^1.7.5", 20 | "@remix-run/react": "^1.7.5", 21 | "@remix-run/serve": "^1.7.5", 22 | "dotenv-cli": "^6.0.0", 23 | "isbot": "^3.6.5", 24 | "react": "^18.2.0", 25 | "react-dom": "^18.2.0", 26 | "zod": "^3.19.1" 27 | }, 28 | "devDependencies": { 29 | "@remix-run/dev": "^1.7.5", 30 | "@remix-run/eslint-config": "^1.7.5", 31 | "@types/react": "^18.0.25", 32 | "@types/react-dom": "^18.0.8", 33 | "eslint": "^8.27.0", 34 | "npm-run-all": "^4.1.5", 35 | "patch-package": "^6.5.0", 36 | "prettier": "^2.7.1", 37 | "remix-flat-routes": "^0.4.8", 38 | "rimraf": "^3.0.2", 39 | "tailwindcss": "^3.2.4", 40 | "typescript": "^4.8.4" 41 | }, 42 | "engines": { 43 | "node": ">=14" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /error-logging/patches/@remix-run+dev+1.7.5.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@remix-run/dev/dist/compiler/compileBrowser.js b/node_modules/@remix-run/dev/dist/compiler/compileBrowser.js 2 | index 36f250a..90244ea 100644 3 | --- a/node_modules/@remix-run/dev/dist/compiler/compileBrowser.js 4 | +++ b/node_modules/@remix-run/dev/dist/compiler/compileBrowser.js 5 | @@ -82,7 +82,7 @@ const createEsbuildConfig = (config, options) => { 6 | entryPoints[id] = config.routes[id].file + "?browser"; 7 | } 8 | 9 | - let plugins = [cssFilePlugin.cssFilePlugin(options), urlImportsPlugin.urlImportsPlugin(), mdx.mdxPlugin(config), browserRouteModulesPlugin.browserRouteModulesPlugin(config, /\?browser$/), emptyModulesPlugin.emptyModulesPlugin(config, /\.server(\.[jt]sx?)?$/), nodeModulesPolyfill.NodeModulesPolyfillPlugin(), esbuildPluginPnp.pnpPlugin()]; 10 | + let plugins = [cssFilePlugin.cssFilePlugin(options), urlImportsPlugin.urlImportsPlugin(), mdx.mdxPlugin(config), browserRouteModulesPlugin.browserRouteModulesPlugin(config, /\?browser$/), emptyModulesPlugin.emptyModulesPlugin(config, /(\.server(\.[jt]sx?)?$)|(\/\w*\.server\/)/), nodeModulesPolyfill.NodeModulesPolyfillPlugin(), esbuildPluginPnp.pnpPlugin()]; 11 | return { 12 | entryPoints, 13 | outdir: config.assetsBuildDirectory, 14 | @@ -135,6 +135,14 @@ const createBrowserCompiler = (remixConfig, options) => { 15 | let manifest = await assets.createAssetsManifest(remixConfig, metafile); 16 | manifestChannel.write(manifest); 17 | await writeAssetsManifest(remixConfig, manifest); 18 | + 19 | + if (metafile) { 20 | + let analysis = await esbuild__namespace.analyzeMetafile(metafile, { 21 | + verbose: true 22 | + }); 23 | + await fs.writeFileSafe(path__namespace.join(remixConfig.assetsBuildDirectory, "meta.json"), JSON.stringify(metafile, null, 2)); 24 | + await fs.writeFileSafe(path__namespace.join(remixConfig.assetsBuildDirectory, "bundle-analysis.txt"), analysis); 25 | + } 26 | }; 27 | 28 | return { 29 | diff --git a/node_modules/@remix-run/dev/dist/compiler/compilerServer.js b/node_modules/@remix-run/dev/dist/compiler/compilerServer.js 30 | index fc9b1e2..01fcea6 100644 31 | --- a/node_modules/@remix-run/dev/dist/compiler/compilerServer.js 32 | +++ b/node_modules/@remix-run/dev/dist/compiler/compilerServer.js 33 | @@ -143,11 +143,22 @@ const createServerCompiler = (remixConfig, options) => { 34 | let compile = async manifestChannel => { 35 | let esbuildConfig = createEsbuildConfig(remixConfig, manifestChannel, options); 36 | let { 37 | - outputFiles 38 | + outputFiles, 39 | + metafile 40 | } = await esbuild__namespace.build({ ...esbuildConfig, 41 | + metafile: true, 42 | write: false 43 | }); 44 | await writeServerBuildResult(remixConfig, outputFiles); 45 | + 46 | + if (metafile) { 47 | + let analysis = await esbuild__namespace.analyzeMetafile(metafile, { 48 | + verbose: true 49 | + }); 50 | + let serverBuildDirectory = path__namespace.dirname(remixConfig.serverBuildPath); 51 | + await fse__namespace.writeFile(path__namespace.join(serverBuildDirectory, "meta.json"), JSON.stringify(metafile, null, 2)); 52 | + await fse__namespace.writeFile(path__namespace.join(serverBuildDirectory, "bundle-analysis.txt"), analysis); 53 | + } 54 | }; 55 | 56 | return { 57 | -------------------------------------------------------------------------------- /error-logging/patches/@remix-run+server-runtime+1.7.5.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@remix-run/server-runtime/dist/esm/server.js b/node_modules/@remix-run/server-runtime/dist/esm/server.js 2 | index 026576d..ea40847 100644 3 | --- a/node_modules/@remix-run/server-runtime/dist/esm/server.js 4 | +++ b/node_modules/@remix-run/server-runtime/dist/esm/server.js 5 | @@ -20,6 +20,10 @@ import { createServerHandoffString } from './serverHandoff.js'; 6 | 7 | // TODO: RRR - Change import to @remix-run/router 8 | const createRequestHandler = (build, mode) => { 9 | + if (build.entry.module.handleError === undefined) { 10 | + build.entry.module.handleError = () => {}; 11 | + } 12 | + 13 | let routes = createRoutes(build.routes); 14 | let serverMode = isServerMode(mode) ? mode : ServerMode.Production; 15 | return async function requestHandler(request, loadContext = {}) { 16 | @@ -37,7 +41,8 @@ const createRequestHandler = (build, mode) => { 17 | request, 18 | loadContext, 19 | matches: matches, 20 | - serverMode 21 | + serverMode, 22 | + handleError: build.entry.module.handleError 23 | }); 24 | let routeId = url.searchParams.get("_data"); 25 | 26 | @@ -60,7 +65,8 @@ const createRequestHandler = (build, mode) => { 27 | request, 28 | loadContext, 29 | matches, 30 | - serverMode 31 | + serverMode, 32 | + handleError: build.entry.module.handleError 33 | }); 34 | 35 | response = await responsePromise; 36 | @@ -71,7 +77,8 @@ const createRequestHandler = (build, mode) => { 37 | matches, 38 | request, 39 | routes, 40 | - serverMode 41 | + serverMode, 42 | + handleError: build.entry.module.handleError 43 | }); 44 | } 45 | 46 | @@ -91,7 +98,8 @@ async function handleDataRequest({ 47 | loadContext, 48 | matches, 49 | request, 50 | - serverMode 51 | + serverMode, 52 | + handleError 53 | }) { 54 | if (!isValidRequestMethod(request)) { 55 | return errorBoundaryError(new Error(`Invalid request method "${request.method}"`), 405); 56 | @@ -161,6 +169,10 @@ async function handleDataRequest({ 57 | } catch (error) { 58 | if (serverMode !== ServerMode.Test) { 59 | console.error(error); 60 | + 61 | + if (error instanceof Error) { 62 | + handleError(request, error, loadContext); 63 | + } 64 | } 65 | 66 | if (serverMode === ServerMode.Development && error instanceof Error) { 67 | @@ -177,7 +189,8 @@ async function handleDocumentRequest({ 68 | matches, 69 | request, 70 | routes, 71 | - serverMode 72 | + serverMode, 73 | + handleError 74 | }) { 75 | let url = new URL(request.url); 76 | let appState = { 77 | @@ -251,6 +264,10 @@ async function handleDocumentRequest({ 78 | 79 | if (serverMode !== ServerMode.Test) { 80 | console.error(`There was an error running the action for route ${actionMatch.route.id}`); 81 | + 82 | + if (error instanceof Error) { 83 | + handleError(request, error, loadContext); 84 | + } 85 | } 86 | } 87 | } 88 | @@ -335,6 +352,10 @@ async function handleDocumentRequest({ 89 | 90 | if (serverMode !== ServerMode.Test) { 91 | console.error(`There was an error running the data loader for route ${match.route.id}`); 92 | + 93 | + if (error instanceof Error) { 94 | + handleError(request, error, loadContext); 95 | + } 96 | } 97 | 98 | break; 99 | @@ -435,7 +456,8 @@ async function handleResourceRequest({ 100 | loadContext, 101 | matches, 102 | request, 103 | - serverMode 104 | + serverMode, 105 | + handleError 106 | }) { 107 | let match = matches.slice(-1)[0]; 108 | 109 | diff --git a/node_modules/@remix-run/server-runtime/dist/server.js b/node_modules/@remix-run/server-runtime/dist/server.js 110 | index 5ac2304..95eae3a 100644 111 | --- a/node_modules/@remix-run/server-runtime/dist/server.js 112 | +++ b/node_modules/@remix-run/server-runtime/dist/server.js 113 | @@ -24,6 +24,10 @@ var serverHandoff = require('./serverHandoff.js'); 114 | 115 | // TODO: RRR - Change import to @remix-run/router 116 | const createRequestHandler = (build, mode$1) => { 117 | + if (build.entry.module.handleError === undefined) { 118 | + build.entry.module.handleError = () => {}; 119 | + } 120 | + 121 | let routes$1 = routes.createRoutes(build.routes); 122 | let serverMode = mode.isServerMode(mode$1) ? mode$1 : mode.ServerMode.Production; 123 | return async function requestHandler(request, loadContext = {}) { 124 | @@ -41,7 +45,8 @@ const createRequestHandler = (build, mode$1) => { 125 | request, 126 | loadContext, 127 | matches: matches, 128 | - serverMode 129 | + serverMode, 130 | + handleError: build.entry.module.handleError 131 | }); 132 | let routeId = url.searchParams.get("_data"); 133 | 134 | @@ -64,7 +69,8 @@ const createRequestHandler = (build, mode$1) => { 135 | request, 136 | loadContext, 137 | matches, 138 | - serverMode 139 | + serverMode, 140 | + handleError: build.entry.module.handleError 141 | }); 142 | 143 | response = await responsePromise; 144 | @@ -75,7 +81,8 @@ const createRequestHandler = (build, mode$1) => { 145 | matches, 146 | request, 147 | routes: routes$1, 148 | - serverMode 149 | + serverMode, 150 | + handleError: build.entry.module.handleError 151 | }); 152 | } 153 | 154 | @@ -95,7 +102,8 @@ async function handleDataRequest({ 155 | loadContext, 156 | matches, 157 | request, 158 | - serverMode 159 | + serverMode, 160 | + handleError 161 | }) { 162 | if (!isValidRequestMethod(request)) { 163 | return errorBoundaryError(new Error(`Invalid request method "${request.method}"`), 405); 164 | @@ -165,6 +173,10 @@ async function handleDataRequest({ 165 | } catch (error) { 166 | if (serverMode !== mode.ServerMode.Test) { 167 | console.error(error); 168 | + 169 | + if (error instanceof Error) { 170 | + handleError(request, error, loadContext); 171 | + } 172 | } 173 | 174 | if (serverMode === mode.ServerMode.Development && error instanceof Error) { 175 | @@ -181,7 +193,8 @@ async function handleDocumentRequest({ 176 | matches, 177 | request, 178 | routes, 179 | - serverMode 180 | + serverMode, 181 | + handleError 182 | }) { 183 | let url = new URL(request.url); 184 | let appState = { 185 | @@ -255,6 +268,10 @@ async function handleDocumentRequest({ 186 | 187 | if (serverMode !== mode.ServerMode.Test) { 188 | console.error(`There was an error running the action for route ${actionMatch.route.id}`); 189 | + 190 | + if (error instanceof Error) { 191 | + handleError(request, error, loadContext); 192 | + } 193 | } 194 | } 195 | } 196 | @@ -339,6 +356,10 @@ async function handleDocumentRequest({ 197 | 198 | if (serverMode !== mode.ServerMode.Test) { 199 | console.error(`There was an error running the data loader for route ${match.route.id}`); 200 | + 201 | + if (error instanceof Error) { 202 | + handleError(request, error, loadContext); 203 | + } 204 | } 205 | 206 | break; 207 | @@ -439,7 +460,8 @@ async function handleResourceRequest({ 208 | loadContext, 209 | matches, 210 | request, 211 | - serverMode 212 | + serverMode, 213 | + handleError 214 | }) { 215 | let match = matches.slice(-1)[0]; 216 | 217 | -------------------------------------------------------------------------------- /error-logging/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiliman/remix-workshop/1ce54f816670bd791ad14c333affe3539eaa8848/error-logging/public/favicon.ico -------------------------------------------------------------------------------- /error-logging/remix.config.js: -------------------------------------------------------------------------------- 1 | const { flatRoutes } = require('remix-flat-routes') 2 | 3 | /** 4 | * @type {import("@remix-run/dev").AppConfig} 5 | */ 6 | module.exports = { 7 | // ignore all files in routes folder to prevent 8 | // default remix convention from picking up routes 9 | ignoredRouteFiles: ['**/*'], 10 | routes: async defineRoutes => { 11 | return flatRoutes('routes', defineRoutes, { 12 | basePath: '/', // optional base path (defaults to /) 13 | paramPrefixChar: '$', // optional specify param prefix 14 | ignoredRouteFiles: [], // same as remix config 15 | }) 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /error-logging/remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /error-logging/scripts/upload-sourcemap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | CWD=$(pwd) 3 | 4 | set -a 5 | source ".env" 6 | set +a 7 | 8 | echo $BUGSNAG_API_KEY 9 | 10 | curl -X POST https://upload.bugsnag.com/ -F apiKey=$BUGSNAG_API_KEY \ 11 | -F sourceMap=@build/index.js.map \ 12 | -F minifiedFile=@build/index.js \ 13 | -F minifiedUrl=http://localhost:3000/* \ 14 | -F projectRoot=$CWD \ 15 | -F overwrite=true \ 16 | 17 | -------------------------------------------------------------------------------- /error-logging/slides/00.md: -------------------------------------------------------------------------------- 1 | # Handling Errors in Remix 2 | 3 | This sample will show different strategies for dealing with errors in a Remix app 4 | 5 | 👋 I'm Michael Carter aka Kiliman 6 | 7 | - Repo https://github.com/kiliman/remix-workshop/error-logging 8 | - Twitter https://twitter.com/kiliman 9 | - GitHub https://github.com/kiliman 10 | - Blog https://kiliman.dev 11 | -------------------------------------------------------------------------------- /error-logging/slides/01.md: -------------------------------------------------------------------------------- 1 | # What is an error? 2 | 3 | First of all, we need to define what an error is, as there are different types. 4 | 5 | 1. Expected errors 6 | 2. Bad client requests 7 | 3. Unexpected server errors 8 | 9 | ## Expected Errors 10 | 11 | These errors are typically caused during request validation. The user has sent us some information that is not in the correct format. We want to let the user know what the problems are and let them try to submit the data again. 12 | 13 | These types of errors should be returned to the client from your `action`. Do not throw an `Error` or a `Response`. 14 | 15 | ## Bad Client Requests 16 | 17 | Again, these errors are due to bad data being sent from the client. Typically these will be invalid URL params or search params. It can also include errors such as missing data (Not Found) or unauthorized access to a resource. 18 | 19 | In most cases you will want to `throw new Response()` here. You're essentially saying, I'm unable to process your request with the information you provided. For the most part, you will want to display the error in your ``. 20 | 21 | ## Unexpected Server Errors 22 | 23 | Finally, the last category of errors are those that are _unexpected_. These can be anywhere from accessing a null/undefined value. Or an `Error` that is thrown when calling some function. 24 | 25 | It is recommended that you don't throw `Error` directly in your application code. It is much better to do null/undefined checks or verify you're calling the function correctly. This class of errors should be strictly for _unexpected_ errors. These errors will be rendered in you ``. 26 | 27 | It is also not recommended to use `try/catch` in your application code, unless you're planning on handling the error. It is not a good idea to have an empty `catch` handler, which simply swallows the error and can leave your application in an unexpected state. 28 | -------------------------------------------------------------------------------- /error-logging/slides/02.md: -------------------------------------------------------------------------------- 1 | # Handling Form Validation 2 | 3 | The bulk of your application will deal with validating user input. Remember that even if you add client-side validation, anything can make a request to your app with unverified data. It is critical that you **always** do server-side validation. 4 | 5 | I recommend using the `zod` package to handle validation. It is very flexible and allows you to easily describe the expected structure of data. 6 | 7 | I also created a simple helper library called `remix-params-helper`. This package has functions to parse and validate data from `URLSearchParams`, `FormData`, as well as the Remix `params` object. It uses `zod` for validation. 8 | 9 | ## Validating URL Params 10 | 11 | A typical use case is validating the `params` object has the correct data. 12 | 13 | Here, we expect the param `$userId` to be a number. The function `getParamsOrThrow` will validate against the schema, and if not valid, with throw a new `Response('Bad Request', { status: 400 })`. If valid, then will return `userId` with the correct type: `number`. 14 | 15 | By throwing a Response, we ensure that the rest of the code can focus on the _happy path_. 16 | 17 | ```ts 18 | // routes/users.$userId_.edit.tsx 19 | export const loader = async ({ params }: LoaderArgs) => { 20 | const { userId } = getParamsOrThrow(params, z.object({ userId: z.number() })) 21 | const user = await getUser(userId) 22 | return typedjson({ user }) 23 | } 24 | ``` 25 | 26 | ## Form Validation 27 | 28 | Here we are validating the submitted form against a schema. We use the same `getParamsOrThrow` to get the userId. We then call `getFormData` with the request and schema. This returns a tuple of `errors`, `data`, and `fields`. 29 | 30 | - `errors` contains any validation errors or null if no errors 31 | - `data` contains the parsed data if successful (data will be converted to the correct types) 32 | - `fields` if there are any errors, fields will contain the original data that was submitted 33 | 34 | As stated before, validation errors are common and expected. We should return the error to the user so they can correct it and resubmit. 35 | 36 | Here we check if there are any errors, and if so, return `invalid({errors, fields})`. `invalid` returns a new Response with status 400. 37 | 38 | If successful, we redirect back to the users list. 39 | 40 | ```ts 41 | const formSchema = z.object({ 42 | name: z.string(), 43 | email: z.string().email(), 44 | age: z.number(), 45 | }) 46 | 47 | export const action = async ({ request, params }: ActionArgs) => { 48 | const { userId } = getParamsOrThrow(params, z.object({ userId: z.number() })) 49 | const [errors, data, fields] = await getFormData(request, formSchema) 50 | if (errors) { 51 | return invalid({ errors, fields }) 52 | } 53 | const { name, email, age } = data 54 | await saveUser(userId, { name, email, age }) 55 | 56 | return redirect('/users/') 57 | } 58 | ``` 59 | 60 | In route component, we typically get the initial data from the `loader` via `useLoaderData` (in this case `useTypedLoaderData`). We also want any errors (if present) returned from the `action` using `useActionData`. 61 | 62 | There are a few other helper functions that are used here: 63 | 64 | - `getInvalid` this function will check the returned action data for the `errors` and `fields` objects 65 | - `getField` this function is used to set the `defaultValue` of the form element 66 | 67 | Since `defaultValue` is only initialized when the form is mounted, it will not reset automatically when new data is present. You would need to unmount the component, typically by setting a new `key` value. 68 | 69 | The `getField` function takes the initial data (`user`) from your loader, as well as the `fields` data if present. The third argument is the property name. The function will return the value from `fields[key]` if present, otherwise `data[key]` is used. This is keyed off the type of the initial data (`User`). This ensures that you will get a Typescript error if you mistype the property name. 70 | 71 | Finally, the `errors` object returns any error messages tied to the specific property. You can check if `errors.fieldname` has a value, and if so render the message. 72 | 73 | ```ts 74 | export default function Index() { 75 | const { user } = useTypedLoaderData() 76 | const data = useTypedActionData() 77 | const [errors, fields] = getInvalid(data) 78 | 79 | return ( 80 |
81 |
82 |
83 | 84 | 90 | {errors.name &&

{errors.name}

} 91 |
92 |
93 | 94 | 100 | {errors.email &&

{errors.email}

} 101 |
102 |
103 | 104 | 110 | {errors.age &&

{errors.age}

} 111 |
112 | 118 |
119 |
120 | ) 121 | } 122 | ``` 123 | -------------------------------------------------------------------------------- /error-logging/slides/03.md: -------------------------------------------------------------------------------- 1 | # Handling Server Side Errors 2 | 3 | By default, Remix will return any thrown `Error` and render the nearest `ErrorBoundary`. It will also log the error to the server console. This typically includes the stack trace. 4 | 5 | For `production`, you typically want to use a bug logging service to capture errors. Some services, like Sentry, have provided a package that integrates with Remix. However, if you're using a service that doesn't, there's not an easy way to handle all errors from Remix in order to log your error. 6 | 7 | I've created a patch that adds a `handleError` export in _entry.server_. Here's an example using `Bugsnag`. You first initialize bugsnag with your API key, then any errors throw in your Remix app will call the `handleError` function. This is where you call your logging function. 8 | 9 | ```ts 10 | // entry.server.tsx 11 | import Bugsnag from '@bugsnag/js' 12 | 13 | Bugsnag.start({ 14 | apiKey: process.env.BUGSNAG_API_KEY!, 15 | }) 16 | 17 | export function handleError(request: Request, error: Error, context: any) { 18 | console.log('notify bugsnag', error) 19 | Bugsnag.notify(error, event => { 20 | event.addMetadata('request', { 21 | url: request.url, 22 | method: request.method, 23 | headers: Object.fromEntries(request.headers.entries()), 24 | }) 25 | }) 26 | } 27 | ``` 28 | 29 | NOTE: In order to get a meaningful stack trace, you need to ensure that _sourcemaps_ are enabled. This is turned on by default for `development`. However, in `production`, Remix does not include the sourcemap by default. The main reason is that in browser bundles, the sourcemap includes the entire source file, loaders and actions included. 30 | 31 | Most logging services allow you to upload the sourcemap to the server. You can turn on sourcemaps for the production build, upload the sourcemaps to the server, then delete them before deploying the app to your host. The logging service will then use the uploaded sourcemaps to generate the correct stack trace. 32 | -------------------------------------------------------------------------------- /error-logging/slides/99.md: -------------------------------------------------------------------------------- 1 | # Thank you! 2 | 3 | 👋 4 | 5 | - Repo https://github.com/kiliman/remix-workshop/error-logging 6 | - Twitter https://twitter.com/kiliman 7 | - GitHub https://github.com/kiliman 8 | - Blog https://kiliman.dev 9 | -------------------------------------------------------------------------------- /error-logging/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./app/**/*.{js,ts,tsx}'], 3 | } 4 | -------------------------------------------------------------------------------- /error-logging/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ES2019", 11 | "strict": true, 12 | "allowJs": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "~/*": ["./app/*"] 17 | }, 18 | 19 | // Remix takes care of building everything in `remix build`. 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /multi-page-forms/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | }; 5 | -------------------------------------------------------------------------------- /multi-page-forms/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | -------------------------------------------------------------------------------- /multi-page-forms/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix! 2 | 3 | - [Remix Docs](https://remix.run/docs) 4 | 5 | ## Development 6 | 7 | From your terminal: 8 | 9 | ```sh 10 | npm run dev 11 | ``` 12 | 13 | This starts your app in development mode, rebuilding assets on file changes. 14 | 15 | ## Deployment 16 | 17 | First, build your app for production: 18 | 19 | ```sh 20 | npm run build 21 | ``` 22 | 23 | Then run the app in production mode: 24 | 25 | ```sh 26 | npm start 27 | ``` 28 | 29 | Now you'll need to pick a host to deploy it to. 30 | 31 | ### DIY 32 | 33 | If you're familiar with deploying node applications, the built-in Remix app server is production-ready. 34 | 35 | Make sure to deploy the output of `remix build` 36 | 37 | - `build/` 38 | - `public/build/` 39 | 40 | ### Using a Template 41 | 42 | When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server. 43 | 44 | ```sh 45 | cd .. 46 | # create a new project, and pick a pre-configured host 47 | npx create-remix@latest 48 | cd my-new-remix-app 49 | # remove the new project's app (not the old one!) 50 | rm -rf app 51 | # copy your app over 52 | cp -R ../my-old-remix-app/app app 53 | ``` 54 | -------------------------------------------------------------------------------- /multi-page-forms/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { startTransition, StrictMode } from "react"; 3 | import { hydrateRoot } from "react-dom/client"; 4 | 5 | function hydrate() { 6 | startTransition(() => { 7 | hydrateRoot( 8 | document, 9 | 10 | 11 | 12 | ); 13 | }); 14 | } 15 | 16 | if (window.requestIdleCallback) { 17 | window.requestIdleCallback(hydrate); 18 | } else { 19 | // Safari doesn't support requestIdleCallback 20 | // https://caniuse.com/requestidlecallback 21 | window.setTimeout(hydrate, 1); 22 | } 23 | -------------------------------------------------------------------------------- /multi-page-forms/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { PassThrough } from "stream"; 2 | import type { EntryContext } from "@remix-run/node"; 3 | import { Response } from "@remix-run/node"; 4 | import { RemixServer } from "@remix-run/react"; 5 | import isbot from "isbot"; 6 | import { renderToPipeableStream } from "react-dom/server"; 7 | 8 | const ABORT_DELAY = 5000; 9 | 10 | export default function handleRequest( 11 | request: Request, 12 | responseStatusCode: number, 13 | responseHeaders: Headers, 14 | remixContext: EntryContext 15 | ) { 16 | return isbot(request.headers.get("user-agent")) 17 | ? handleBotRequest( 18 | request, 19 | responseStatusCode, 20 | responseHeaders, 21 | remixContext 22 | ) 23 | : handleBrowserRequest( 24 | request, 25 | responseStatusCode, 26 | responseHeaders, 27 | remixContext 28 | ); 29 | } 30 | 31 | function handleBotRequest( 32 | request: Request, 33 | responseStatusCode: number, 34 | responseHeaders: Headers, 35 | remixContext: EntryContext 36 | ) { 37 | return new Promise((resolve, reject) => { 38 | let didError = false; 39 | 40 | const { pipe, abort } = renderToPipeableStream( 41 | , 42 | { 43 | onAllReady() { 44 | const body = new PassThrough(); 45 | 46 | responseHeaders.set("Content-Type", "text/html"); 47 | 48 | resolve( 49 | new Response(body, { 50 | headers: responseHeaders, 51 | status: didError ? 500 : responseStatusCode, 52 | }) 53 | ); 54 | 55 | pipe(body); 56 | }, 57 | onShellError(error: unknown) { 58 | reject(error); 59 | }, 60 | onError(error: unknown) { 61 | didError = true; 62 | 63 | console.error(error); 64 | }, 65 | } 66 | ); 67 | 68 | setTimeout(abort, ABORT_DELAY); 69 | }); 70 | } 71 | 72 | function handleBrowserRequest( 73 | request: Request, 74 | responseStatusCode: number, 75 | responseHeaders: Headers, 76 | remixContext: EntryContext 77 | ) { 78 | return new Promise((resolve, reject) => { 79 | let didError = false; 80 | 81 | const { pipe, abort } = renderToPipeableStream( 82 | , 83 | { 84 | onShellReady() { 85 | const body = new PassThrough(); 86 | 87 | responseHeaders.set("Content-Type", "text/html"); 88 | 89 | resolve( 90 | new Response(body, { 91 | headers: responseHeaders, 92 | status: didError ? 500 : responseStatusCode, 93 | }) 94 | ); 95 | 96 | pipe(body); 97 | }, 98 | onShellError(err: unknown) { 99 | reject(err); 100 | }, 101 | onError(error: unknown) { 102 | didError = true; 103 | 104 | console.error(error); 105 | }, 106 | } 107 | ); 108 | 109 | setTimeout(abort, ABORT_DELAY); 110 | }); 111 | } 112 | -------------------------------------------------------------------------------- /multi-page-forms/app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { LinksFunction, MetaFunction } from "@remix-run/node"; 2 | import { 3 | Links, 4 | LiveReload, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | } from "@remix-run/react"; 10 | import tailwindCss from "~/styles/tailwind.css"; 11 | 12 | export const links: LinksFunction = () => [ 13 | { rel: "stylesheet", href: tailwindCss }, 14 | ]; 15 | 16 | export const meta: MetaFunction = () => ({ 17 | charset: "utf-8", 18 | title: "Remix Multi-Page Forms", 19 | viewport: "width=device-width,initial-scale=1", 20 | }); 21 | 22 | export default function App() { 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /multi-page-forms/app/session.server.ts: -------------------------------------------------------------------------------- 1 | // app/sessions.js 2 | import { createCookieSessionStorage } from "@remix-run/node"; 3 | 4 | const { getSession, commitSession, destroySession } = 5 | createCookieSessionStorage({ 6 | // a Cookie from `createCookie` or the CookieOptions to create one 7 | cookie: { 8 | name: "__session", 9 | httpOnly: true, 10 | path: "/", 11 | sameSite: "lax", 12 | secure: true, 13 | }, 14 | }); 15 | 16 | export { getSession, commitSession, destroySession }; 17 | -------------------------------------------------------------------------------- /multi-page-forms/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "sideEffects": false, 4 | "scripts": { 5 | "clean": "rimraf build public/build", 6 | "prebuild": "npm run clean", 7 | "build": "remix build --sourcemap", 8 | "predev": "npm run clean && npm run css:generate", 9 | "dev": "run-p dev:*", 10 | "dev:remix": "remix dev", 11 | "dev:tailwind": "npm run css:generate -- --watch", 12 | "css:generate": "tailwindcss -o ./app/styles/tailwind.css", 13 | "start": "dotenv remix-serve build" 14 | }, 15 | "dependencies": { 16 | "@remix-run/node": "^1.7.5", 17 | "@remix-run/react": "^1.7.5", 18 | "@remix-run/serve": "^1.7.5", 19 | "clsx": "^1.2.1", 20 | "dotenv-cli": "^6.0.0", 21 | "isbot": "^3.6.5", 22 | "qs": "^6.11.0", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "spin-delay": "^1.2.0" 26 | }, 27 | "devDependencies": { 28 | "@remix-run/dev": "^1.7.5", 29 | "@remix-run/eslint-config": "^1.7.5", 30 | "@tailwindcss/aspect-ratio": "^0.4.2", 31 | "@tailwindcss/forms": "^0.5.3", 32 | "@tailwindcss/line-clamp": "^0.4.2", 33 | "@tailwindcss/typography": "^0.5.8", 34 | "@types/qs": "^6.9.7", 35 | "@types/react": "^18.0.25", 36 | "@types/react-dom": "^18.0.8", 37 | "eslint": "^8.27.0", 38 | "npm-run-all": "^4.1.5", 39 | "rimraf": "^3.0.2", 40 | "tailwindcss": "^3.2.4", 41 | "typescript": "^4.8.4" 42 | }, 43 | "engines": { 44 | "node": ">=14" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /multi-page-forms/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiliman/remix-workshop/1ce54f816670bd791ad14c333affe3539eaa8848/multi-page-forms/public/favicon.ico -------------------------------------------------------------------------------- /multi-page-forms/remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | module.exports = { 3 | ignoredRouteFiles: ["**/.*"], 4 | // appDirectory: "app", 5 | // assetsBuildDirectory: "public/build", 6 | // serverBuildPath: "build/index.js", 7 | // publicPath: "/build/", 8 | }; 9 | -------------------------------------------------------------------------------- /multi-page-forms/remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /multi-page-forms/slides/00.md: -------------------------------------------------------------------------------- 1 | # Multi-Page Forms with Remix 2 | 3 | Sample showing how to render and process multi-page forms 4 | 5 | 👋 I'm Michael Carter aka Kiliman 6 | 7 | - Repo https://github.com/kiliman/remix-workshop/multi-page-forms 8 | - Twitter https://twitter.com/kiliman 9 | - GitHub https://github.com/kiliman 10 | - Blog https://kiliman.dev 11 | -------------------------------------------------------------------------------- /multi-page-forms/slides/01.md: -------------------------------------------------------------------------------- 1 | # Rendering multi-page forms 2 | 3 | There are many ways to render a multi-page form. For example, you could have a single route with a single form, and simply show or hide the inputs based on which page the user is currently on. 4 | 5 | With this way, it is important that you return the current page's data, since no local state is used. This way if the user navigates back to a previous page, the data will be restored. 6 | 7 | ```ts 8 | export const loader = async ({ request }: LoaderArgs) => { 9 | const url = new URL(request.url); 10 | const page = Number(url.searchParams.get("page") ?? "1"); 11 | const session = await getSession(request.headers.get("cookie")); 12 | 13 | if (page < 4) { 14 | const data = session.get(`form-data-page-${page}`) ?? {}; 15 | return json({ page, data }); 16 | } else { 17 | // final page so just collect all the data to render 18 | const data = { 19 | ...session.get(`form-data-page-1`), 20 | ...session.get(`form-data-page-2`), 21 | ...session.get(`form-data-page-3`), 22 | }; 23 | return json({ page, data }); 24 | } 25 | }; 26 | 27 | export default function MultiPageForm() { 28 | const transition = useTransition(); 29 | const showSpinner = useSpinDelay(transition.state !== "idle", { 30 | delay: 200, 31 | minDuration: 300, 32 | }); 33 | 34 | const loaderData = useLoaderData(); 35 | const page = Number(loaderData.page); 36 | const data = loaderData.data; 37 | 38 | return ( 39 |
40 |
41 | 42 |
43 | {page === 1 &&
Show page 1 form...
} 44 | {page === 2 &&
Show page 2 form...
} 45 | {page === 3 &&
Show page 3 form...
} 46 | {page === 4 &&
Show final results...
} 47 |
48 |
49 |
50 | ); 51 | } 52 | ``` 53 | 54 | Another option would be to create separate routes, where each route has it's own `Form` and `loader/action`. This should probably only be used for really complex forms, where you need extra logic in your loader and action. 55 | 56 | I've also implemented a `multi-section` form. Instead of navigating through different pages, there is a single form with collapsible panels. Each panel is a mini-form. These panels are separate components where all the form logic is component-specific, but they use the same data from the route loader and post to the same action. 57 | -------------------------------------------------------------------------------- /multi-page-forms/slides/02.md: -------------------------------------------------------------------------------- 1 | # Managing data for multi-page forms 2 | 3 | There are several ways you can store the data for a multi-page form as the user navigates each page. 4 | 5 | - Store each "page" in session 6 | - Save each "page" in the database 7 | - Use local storage in the browser 8 | 9 | ## Store data in session 10 | 11 | This is the method the example uses. As the user navigates each page, the submitted form data is added to the current form session key by page number. 12 | 13 | When the final page has been submitted, you should have all the data needed to persist the data to your database. 14 | 15 | ### Pros 16 | 17 | - Simple to use 18 | - Data persists as long as session exists 19 | 20 | ### Cons 21 | 22 | - If using cookie storage, session can get big depending on data 23 | - Users loses data if session expires 24 | - Can't continue from different browser 25 | 26 | ```ts 27 | export const action = async ({ request }: LoaderArgs) => { 28 | const text = await request.text(); 29 | // use qs.parse to support multi-value values (by email checkbox list) 30 | const { page, action, ...data } = qs.parse(text); 31 | const session = await getSession(request.headers.get("cookie")); 32 | session.set(`form-data-page-${page}`, data); 33 | 34 | const nextPage = Number(page) + (action === "next" ? 1 : -1); 35 | return redirect(`?page=${nextPage}`, { 36 | headers: { 37 | "set-cookie": await commitSession(session), 38 | }, 39 | }); 40 | }; 41 | ``` 42 | 43 | ## Store data in database 44 | 45 | Similar to the session storage, you can save each page to the database as the user submits. The main benefit is that the user can close the browser and pick up later as well as from a different computer. 46 | 47 | Depending on how the data is stored, and the form itself, it may be difficult to save only partial data, since required fields may not be entered. Also data with relations may be hard to save if the parent data is not available to relate the foreign key. 48 | 49 | ### Pros 50 | 51 | - Allows you to durably save the data for each page 52 | - User can come back and pick up where he left off, even on another computer 53 | 54 | ### Cons 55 | 56 | - Depending on data model, may have issues with required fields or relations 57 | 58 | ## Store data local in browser 59 | 60 | Although this is technically an option, it is not recommended to use local storage. Since Remix doesn't have access to the data from your `action`, you will need to use client-side Javascript to save the data locally. This partly defeats the purpose of Remix. 61 | 62 | ### Pros 63 | 64 | - Able to save form data even if the user is offline 65 | 66 | ### Cons 67 | 68 | - Requires extra client-side Javascript to manage form submissions 69 | -------------------------------------------------------------------------------- /multi-page-forms/slides/99.md: -------------------------------------------------------------------------------- 1 | # Thank you! 2 | 3 | 👋 4 | 5 | - Repo https://github.com/kiliman/remix-workshop/multi-page-forms 6 | - Twitter https://twitter.com/kiliman 7 | - GitHub https://github.com/kiliman 8 | - Blog https://kiliman.dev 9 | -------------------------------------------------------------------------------- /multi-page-forms/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./app/**/*.{js,ts,tsx}"], 3 | plugins: [ 4 | require("@tailwindcss/forms"), 5 | require("@tailwindcss/typography"), 6 | require("@tailwindcss/aspect-ratio"), 7 | require("@tailwindcss/line-clamp"), 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /multi-page-forms/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ES2019", 11 | "strict": true, 12 | "allowJs": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "~/*": ["./app/*"] 17 | }, 18 | 19 | // Remix takes care of building everything in `remix build`. 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /multi-tenant-prisma/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" 2 | SESSION_SECRET="super-duper-s3cret" 3 | -------------------------------------------------------------------------------- /multi-tenant-prisma/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | }; 5 | -------------------------------------------------------------------------------- /multi-tenant-prisma/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | 8 | /postgres-data 9 | 10 | /app/styles/tailwind.css 11 | -------------------------------------------------------------------------------- /multi-tenant-prisma/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "arrowParens": "avoid" 6 | } -------------------------------------------------------------------------------- /multi-tenant-prisma/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix! 2 | 3 | - [Remix Docs](https://remix.run/docs) 4 | 5 | ## Development 6 | 7 | From your terminal: 8 | 9 | ```sh 10 | npm run dev 11 | ``` 12 | 13 | This starts your app in development mode, rebuilding assets on file changes. 14 | 15 | ## Deployment 16 | 17 | First, build your app for production: 18 | 19 | ```sh 20 | npm run build 21 | ``` 22 | 23 | Then run the app in production mode: 24 | 25 | ```sh 26 | npm start 27 | ``` 28 | 29 | Now you'll need to pick a host to deploy it to. 30 | 31 | ### DIY 32 | 33 | If you're familiar with deploying node applications, the built-in Remix app server is production-ready. 34 | 35 | Make sure to deploy the output of `remix build` 36 | 37 | - `build/` 38 | - `public/build/` 39 | 40 | ### Using a Template 41 | 42 | When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server. 43 | 44 | ```sh 45 | cd .. 46 | # create a new project, and pick a pre-configured host 47 | npx create-remix@latest 48 | cd my-new-remix-app 49 | # remove the new project's app (not the old one!) 50 | rm -rf app 51 | # copy your app over 52 | cp -R ../my-old-remix-app/app app 53 | ``` 54 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { startTransition, StrictMode } from "react"; 3 | import { hydrateRoot } from "react-dom/client"; 4 | 5 | const hydrate = () => { 6 | startTransition(() => { 7 | hydrateRoot( 8 | document, 9 | 10 | 11 | 12 | ); 13 | }); 14 | }; 15 | 16 | if (window.requestIdleCallback) { 17 | window.requestIdleCallback(hydrate); 18 | } else { 19 | // Safari doesn't support requestIdleCallback 20 | // https://caniuse.com/requestidlecallback 21 | window.setTimeout(hydrate, 1); 22 | } 23 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { PassThrough } from "stream"; 2 | import type { EntryContext } from "@remix-run/node"; 3 | import { Response } from "@remix-run/node"; 4 | import { RemixServer } from "@remix-run/react"; 5 | import isbot from "isbot"; 6 | import { renderToPipeableStream } from "react-dom/server"; 7 | 8 | const ABORT_DELAY = 5000; 9 | 10 | export default function handleRequest( 11 | request: Request, 12 | responseStatusCode: number, 13 | responseHeaders: Headers, 14 | remixContext: EntryContext 15 | ) { 16 | const callbackName = isbot(request.headers.get("user-agent")) 17 | ? "onAllReady" 18 | : "onShellReady"; 19 | 20 | return new Promise((resolve, reject) => { 21 | let didError = false; 22 | 23 | const { pipe, abort } = renderToPipeableStream( 24 | , 25 | { 26 | [callbackName]: () => { 27 | const body = new PassThrough(); 28 | 29 | responseHeaders.set("Content-Type", "text/html"); 30 | 31 | resolve( 32 | new Response(body, { 33 | headers: responseHeaders, 34 | status: didError ? 500 : responseStatusCode, 35 | }) 36 | ); 37 | 38 | pipe(body); 39 | }, 40 | onShellError: (err: unknown) => { 41 | reject(err); 42 | }, 43 | onError: (error: unknown) => { 44 | didError = true; 45 | 46 | console.error(error); 47 | }, 48 | } 49 | ); 50 | 51 | setTimeout(abort, ABORT_DELAY); 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/models/note.server.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '~/prisma/db.server' 2 | import type { Note, User } from '~/prisma/tenant' 3 | 4 | export function getNote( 5 | tenantId: string, 6 | { 7 | id, 8 | userId, 9 | }: Pick & { 10 | userId: User['id'] 11 | }, 12 | ) { 13 | return prisma(tenantId).note.findFirst({ 14 | select: { id: true, body: true, title: true }, 15 | where: { id, userId }, 16 | }) 17 | } 18 | 19 | export function getNoteListItems( 20 | tenantId: string, 21 | { userId }: { userId: User['id'] }, 22 | ) { 23 | return prisma(tenantId).note.findMany({ 24 | where: { userId }, 25 | select: { id: true, title: true }, 26 | orderBy: { updatedAt: 'desc' }, 27 | }) 28 | } 29 | 30 | export function createNote( 31 | tenantId: string, 32 | { 33 | body, 34 | title, 35 | userId, 36 | }: Pick & { 37 | userId: User['id'] 38 | }, 39 | ) { 40 | return prisma(tenantId).note.create({ 41 | data: { 42 | title, 43 | body, 44 | user: { 45 | connect: { 46 | id: userId, 47 | }, 48 | }, 49 | }, 50 | }) 51 | } 52 | 53 | export function deleteNote( 54 | tenantId: string, 55 | { id, userId }: Pick & { userId: User['id'] }, 56 | ) { 57 | return prisma(tenantId).note.deleteMany({ 58 | where: { id, userId }, 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/models/tenant.server.ts: -------------------------------------------------------------------------------- 1 | import { prismaPublic } from '~/prisma/db.server' 2 | import { type Tenant } from '~/prisma/public' 3 | export { type Tenant } 4 | 5 | export function requireTenantId(request: Request) { 6 | const tenantId = getTenantId(request) 7 | if (!tenantId) { 8 | throw new Response('Tenant ID is required', { status: 404 }) 9 | } 10 | return tenantId 11 | } 12 | 13 | export function getTenantId(request: Request) { 14 | const url = new URL(request.url) 15 | const parts = url.hostname.split('.') 16 | let tenantId: Tenant['id'] | undefined 17 | if (parts.length >= 3) { 18 | tenantId = parts[parts.length - 3] 19 | } 20 | 21 | return tenantId 22 | } 23 | 24 | export function getTenantById(tenantId: Tenant['id']) { 25 | return prismaPublic().tenant.findUnique({ where: { id: tenantId } }) 26 | } 27 | 28 | export function addTenant(name: Tenant['name'], host: Tenant['host']) { 29 | return prismaPublic().tenant.create({ data: { id: host, name, host } }) 30 | } 31 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/models/user.server.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs' 2 | import { prisma } from '~/prisma/db.server' 3 | import type { Password, User } from '~/prisma/tenant' 4 | export type { User } 5 | 6 | export async function getUserById(tenantId: string, id: User['id']) { 7 | return prisma(tenantId).user.findUnique({ where: { id } }) 8 | } 9 | 10 | export async function getUserByEmail(tenantId: string, email: User['email']) { 11 | return prisma(tenantId).user.findUnique({ where: { email } }) 12 | } 13 | 14 | export async function createUser( 15 | tenantId: string, 16 | email: User['email'], 17 | password: string, 18 | ) { 19 | const hashedPassword = await bcrypt.hash(password, 10) 20 | 21 | return prisma(tenantId).user.create({ 22 | data: { 23 | email, 24 | password: { 25 | create: { 26 | hash: hashedPassword, 27 | }, 28 | }, 29 | }, 30 | }) 31 | } 32 | 33 | export async function deleteUserByEmail( 34 | tenantId: string, 35 | email: User['email'], 36 | ) { 37 | return prisma(tenantId).user.delete({ where: { email } }) 38 | } 39 | 40 | export async function verifyLogin( 41 | tenantId: string, 42 | email: User['email'], 43 | password: Password['hash'], 44 | ) { 45 | const userWithPassword = await prisma(tenantId).user.findUnique({ 46 | where: { email }, 47 | include: { 48 | password: true, 49 | }, 50 | }) 51 | 52 | if (!userWithPassword || !userWithPassword.password) { 53 | return null 54 | } 55 | 56 | const isValid = await bcrypt.compare(password, userWithPassword.password.hash) 57 | 58 | if (!isValid) { 59 | return null 60 | } 61 | 62 | const { password: _password, ...userWithoutPassword } = userWithPassword 63 | 64 | return userWithoutPassword 65 | } 66 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/prisma/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient as PrismaClientPublic } from '@prisma/client/public' 2 | import { PrismaClient as PrismaClientTenant } from '@prisma/client/tenant' 3 | import invariant from 'tiny-invariant' 4 | 5 | declare global { 6 | var __db_public__: PrismaClientPublic 7 | var __db_clients__: Map 8 | } 9 | 10 | let _public: PrismaClientPublic 11 | let _clients: Map 12 | 13 | if (process.env.NODE_ENV === 'production') { 14 | _public = prismaPublic() 15 | _clients = new Map() 16 | } else { 17 | if (!global.__db_public__) { 18 | global.__db_public__ = prismaPublic() 19 | } 20 | if (!global.__db_clients__) { 21 | global.__db_clients__ = new Map() 22 | } 23 | _public = global.__db_public__ 24 | _clients = global.__db_clients__ 25 | } 26 | 27 | export function prismaPublic() { 28 | let { DATABASE_URL } = process.env 29 | invariant(typeof DATABASE_URL === 'string', 'DATABASE_URL env var not set') 30 | if (_public) return _public 31 | 32 | let databaseUrl = `${DATABASE_URL}?schema=public` 33 | 34 | const client = new PrismaClientPublic({ 35 | datasources: { 36 | db: { 37 | url: databaseUrl.toString(), 38 | }, 39 | }, 40 | }) 41 | // connect eagerly 42 | client.$connect() 43 | _public = client 44 | 45 | return client 46 | } 47 | 48 | export function prisma(tenantId: string) { 49 | let { DATABASE_URL } = process.env 50 | invariant(typeof DATABASE_URL === 'string', 'DATABASE_URL env var not set') 51 | if (_clients.has(tenantId)) { 52 | let client = _clients.get(tenantId) 53 | invariant(client, 'client should be defined') 54 | return client 55 | } 56 | 57 | let databaseUrl = `${DATABASE_URL}?schema=${tenantId}` 58 | 59 | const client = new PrismaClientTenant({ 60 | datasources: { 61 | db: { 62 | url: databaseUrl.toString(), 63 | }, 64 | }, 65 | }) 66 | // connect eagerly 67 | client.$connect() 68 | _clients.set(tenantId, client) 69 | 70 | return client 71 | } 72 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/prisma/public.ts: -------------------------------------------------------------------------------- 1 | export * from '@prisma/client/public' 2 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/prisma/tenant.ts: -------------------------------------------------------------------------------- 1 | export * from ".prisma/client/tenant"; 2 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | LinksFunction, 4 | LoaderArgs, 5 | MetaFunction, 6 | redirect, 7 | } from '@remix-run/node' 8 | import { 9 | Links, 10 | LiveReload, 11 | Meta, 12 | Outlet, 13 | Scripts, 14 | ScrollRestoration, 15 | } from '@remix-run/react' 16 | import { getTenantById, getTenantId } from './models/tenant.server' 17 | 18 | import { getUser } from './session.server' 19 | import tailwindStylesheetUrl from './styles/tailwind.css' 20 | 21 | export const links: LinksFunction = () => { 22 | return [{ rel: 'stylesheet', href: tailwindStylesheetUrl }] 23 | } 24 | 25 | export const meta: MetaFunction = () => ({ 26 | charset: 'utf-8', 27 | title: 'Remix Notes', 28 | viewport: 'width=device-width,initial-scale=1', 29 | }) 30 | 31 | export async function loader({ request }: LoaderArgs) { 32 | let url = new URL(request.url) 33 | const user = await getUser(request) 34 | const tenantId = getTenantId(request) 35 | if (!user && tenantId && url.pathname === '/') { 36 | return redirect('/login') 37 | } 38 | if (user && tenantId && url.pathname === '/') { 39 | return redirect('/notes') 40 | } 41 | 42 | const tenant = tenantId ? await getTenantById(tenantId) : null 43 | return json({ user, tenant }) 44 | } 45 | 46 | export default function App() { 47 | return ( 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/routes/healthcheck.tsx: -------------------------------------------------------------------------------- 1 | // learn more: https://fly.io/docs/reference/configuration/#services-http_checks 2 | import type { LoaderArgs } from "@remix-run/server-runtime"; 3 | import { prisma } from "~/prisma/db.server"; 4 | 5 | export async function loader({ request }: LoaderArgs) { 6 | const host = 7 | request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); 8 | 9 | try { 10 | const url = new URL("/", `http://${host}`); 11 | // if we can connect to the database and make a simple query 12 | // and make a HEAD request to ourselves, then we're good. 13 | await Promise.all([ 14 | prisma.user.count(), 15 | fetch(url.toString(), { method: "HEAD" }).then((r) => { 16 | if (!r.ok) return Promise.reject(r); 17 | }), 18 | ]); 19 | return new Response("OK"); 20 | } catch (error: unknown) { 21 | console.log("healthcheck ❌", { error }); 22 | return new Response("ERROR", { status: 500 }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { json, type ActionArgs } from '@remix-run/node' 2 | import { Form, useActionData, useTransition } from '@remix-run/react' 3 | import { addTenant, getTenantById } from '~/models/tenant.server' 4 | import { provisionTenant } from '~/services/tenant.server' 5 | 6 | export const action = async ({ request }: ActionArgs) => { 7 | const formData = await request.formData() 8 | const name = String(formData.get('name')) 9 | const host = String(formData.get('host')) 10 | 11 | let tenant = await getTenantById(host) 12 | if (tenant) { 13 | return json({ errors: { host: 'Host already exists' } }, { status: 400 }) 14 | } 15 | 16 | tenant = await addTenant(name, host) 17 | provisionTenant(host) 18 | 19 | return json({ tenant }) 20 | } 21 | 22 | export default function Index() { 23 | const { tenant, errors } = useActionData() ?? {} 24 | const transition = useTransition() 25 | 26 | return ( 27 |
28 |
29 |

Tenant Onboarding

30 |
31 |
32 | 38 |
39 | 47 |
48 |
49 |
50 | 56 |
57 | {' '} 65 | .remix.local 66 |
67 | {errors?.host &&

{errors?.host}

} 68 |
69 | 76 |
77 | {transition.state !== 'idle' && ( 78 |

Provisioning tenant... one moment

79 | )} 80 | {tenant && ( 81 | <> 82 |

83 | {tenant.name} successfully created! 84 |

85 | 86 | Login to tenant 87 | 88 | 89 | )} 90 |
91 |
92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/routes/join.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node' 2 | import { json, redirect } from '@remix-run/node' 3 | import { Form, Link, useActionData, useSearchParams } from '@remix-run/react' 4 | import * as React from 'react' 5 | 6 | import { createUserSession, getUserId } from '~/session.server' 7 | 8 | import { requireTenantId } from '~/models/tenant.server' 9 | import { createUser, getUserByEmail } from '~/models/user.server' 10 | import { safeRedirect, useTenant, validateEmail } from '~/utils' 11 | 12 | export async function loader({ request }: LoaderArgs) { 13 | requireTenantId(request) 14 | const userId = await getUserId(request) 15 | if (userId) return redirect('/') 16 | return json({}) 17 | } 18 | 19 | export async function action({ request }: ActionArgs) { 20 | const tenantId = await requireTenantId(request) 21 | const formData = await request.formData() 22 | const email = formData.get('email') 23 | const password = formData.get('password') 24 | const redirectTo = safeRedirect(formData.get('redirectTo'), '/') 25 | 26 | if (!validateEmail(email)) { 27 | return json( 28 | { errors: { email: 'Email is invalid', password: null } }, 29 | { status: 400 }, 30 | ) 31 | } 32 | 33 | if (typeof password !== 'string' || password.length === 0) { 34 | return json( 35 | { errors: { email: null, password: 'Password is required' } }, 36 | { status: 400 }, 37 | ) 38 | } 39 | 40 | if (password.length < 8) { 41 | return json( 42 | { errors: { email: null, password: 'Password is too short' } }, 43 | { status: 400 }, 44 | ) 45 | } 46 | 47 | const existingUser = await getUserByEmail(tenantId, email) 48 | if (existingUser) { 49 | return json( 50 | { 51 | errors: { 52 | email: 'A user already exists with this email', 53 | password: null, 54 | }, 55 | }, 56 | { status: 400 }, 57 | ) 58 | } 59 | 60 | const user = await createUser(tenantId, email, password) 61 | 62 | return createUserSession({ 63 | request, 64 | userId: user.id, 65 | remember: false, 66 | redirectTo, 67 | }) 68 | } 69 | 70 | export const meta: MetaFunction = () => { 71 | return { 72 | title: 'Sign Up', 73 | } 74 | } 75 | 76 | export default function Join() { 77 | const tenant = useTenant() 78 | 79 | const [searchParams] = useSearchParams() 80 | const redirectTo = searchParams.get('redirectTo') ?? undefined 81 | const actionData = useActionData() 82 | const emailRef = React.useRef(null) 83 | const passwordRef = React.useRef(null) 84 | 85 | React.useEffect(() => { 86 | if (actionData?.errors?.email) { 87 | emailRef.current?.focus() 88 | } else if (actionData?.errors?.password) { 89 | passwordRef.current?.focus() 90 | } 91 | }, [actionData]) 92 | 93 | return ( 94 |
95 |
96 |

{tenant.name} Join

97 |
98 |
99 | 105 |
106 | 118 | {actionData?.errors?.email && ( 119 |
120 | {actionData.errors.email} 121 |
122 | )} 123 |
124 |
125 | 126 |
127 | 133 |
134 | 144 | {actionData?.errors?.password && ( 145 |
146 | {actionData.errors.password} 147 |
148 | )} 149 |
150 |
151 | 152 | 153 | 159 |
160 |
161 | Already have an account?{' '} 162 | 169 | Log in 170 | 171 |
172 |
173 |
174 |
175 |
176 | ) 177 | } 178 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node' 2 | import { json, redirect } from '@remix-run/node' 3 | import { Form, Link, useActionData, useSearchParams } from '@remix-run/react' 4 | import * as React from 'react' 5 | import { requireTenantId } from '~/models/tenant.server' 6 | 7 | import { verifyLogin } from '~/models/user.server' 8 | import { createUserSession, getUserId } from '~/session.server' 9 | import { safeRedirect, useTenant, validateEmail } from '~/utils' 10 | 11 | export async function loader({ request }: LoaderArgs) { 12 | requireTenantId(request) 13 | const userId = await getUserId(request) 14 | if (userId) return redirect('/') 15 | return json({}) 16 | } 17 | 18 | export async function action({ request }: ActionArgs) { 19 | const tenantId = requireTenantId(request) 20 | const formData = await request.formData() 21 | const email = formData.get('email') 22 | const password = formData.get('password') 23 | const redirectTo = safeRedirect(formData.get('redirectTo'), '/') 24 | const remember = formData.get('remember') 25 | 26 | if (!validateEmail(email)) { 27 | return json( 28 | { errors: { email: 'Email is invalid', password: null } }, 29 | { status: 400 }, 30 | ) 31 | } 32 | 33 | if (typeof password !== 'string' || password.length === 0) { 34 | return json( 35 | { errors: { password: 'Password is required', email: null } }, 36 | { status: 400 }, 37 | ) 38 | } 39 | 40 | if (password.length < 8) { 41 | return json( 42 | { errors: { password: 'Password is too short', email: null } }, 43 | { status: 400 }, 44 | ) 45 | } 46 | 47 | const user = await verifyLogin(tenantId, email, password) 48 | 49 | if (!user) { 50 | return json( 51 | { errors: { email: 'Invalid email or password', password: null } }, 52 | { status: 400 }, 53 | ) 54 | } 55 | 56 | return createUserSession({ 57 | request, 58 | userId: user.id, 59 | remember: remember === 'on' ? true : false, 60 | redirectTo, 61 | }) 62 | } 63 | 64 | export const meta: MetaFunction = () => { 65 | return { 66 | title: 'Login', 67 | } 68 | } 69 | 70 | export default function LoginPage() { 71 | const tenant = useTenant() 72 | const [searchParams] = useSearchParams() 73 | const redirectTo = searchParams.get('redirectTo') || '/notes' 74 | const actionData = useActionData() 75 | const emailRef = React.useRef(null) 76 | const passwordRef = React.useRef(null) 77 | 78 | React.useEffect(() => { 79 | if (actionData?.errors?.email) { 80 | emailRef.current?.focus() 81 | } else if (actionData?.errors?.password) { 82 | passwordRef.current?.focus() 83 | } 84 | }, [actionData]) 85 | 86 | return ( 87 |
88 |
89 |

{tenant.name} Login

90 |
91 |
92 | 98 |
99 | 111 | {actionData?.errors?.email && ( 112 |
113 | {actionData.errors.email} 114 |
115 | )} 116 |
117 |
118 | 119 |
120 | 126 |
127 | 137 | {actionData?.errors?.password && ( 138 |
139 | {actionData.errors.password} 140 |
141 | )} 142 |
143 |
144 | 145 | 146 | 152 |
153 |
154 | 160 | 166 |
167 |
168 | Don't have an account?{' '} 169 | 176 | Sign up 177 | 178 |
179 |
180 |
181 |
182 |
183 | ) 184 | } 185 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionArgs } from "@remix-run/node"; 2 | import { redirect } from "@remix-run/node"; 3 | 4 | import { logout } from "~/session.server"; 5 | 6 | export async function action({ request }: ActionArgs) { 7 | return logout(request); 8 | } 9 | 10 | export async function loader() { 11 | return redirect("/"); 12 | } 13 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/routes/notes.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from '@remix-run/node' 2 | import { json } from '@remix-run/node' 3 | import { Form, Link, NavLink, Outlet, useLoaderData } from '@remix-run/react' 4 | 5 | import { getNoteListItems } from '~/models/note.server' 6 | import { requireTenantId } from '~/models/tenant.server' 7 | import { requireUserId } from '~/session.server' 8 | import { useTenant, useUser } from '~/utils' 9 | 10 | export async function loader({ request }: LoaderArgs) { 11 | const tenantId = requireTenantId(request) 12 | const userId = await requireUserId(request) 13 | const noteListItems = await getNoteListItems(tenantId, { userId }) 14 | return json({ noteListItems }) 15 | } 16 | 17 | export default function NotesPage() { 18 | const data = useLoaderData() 19 | const tenant = useTenant() 20 | const user = useUser() 21 | 22 | return ( 23 |
24 |
25 |

26 | {tenant.name} Notes 27 |

28 |

{user.email}

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

No notes yet

49 | ) : ( 50 |
    51 | {data.noteListItems.map(note => ( 52 |
  1. 53 | 55 | `block border-b p-4 text-xl ${isActive ? 'bg-white' : ''}` 56 | } 57 | to={note.id} 58 | > 59 | 📝 {note.title} 60 | 61 |
  2. 62 | ))} 63 |
64 | )} 65 |
66 | 67 |
68 | 69 |
70 |
71 |
72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/routes/notes/$noteId.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionArgs, LoaderArgs } from '@remix-run/node' 2 | import { json, redirect } from '@remix-run/node' 3 | import { Form, useCatch, useLoaderData } from '@remix-run/react' 4 | import invariant from 'tiny-invariant' 5 | 6 | import { deleteNote, getNote } from '~/models/note.server' 7 | import { requireTenantId } from '~/models/tenant.server' 8 | import { requireUserId } from '~/session.server' 9 | 10 | export async function loader({ request, params }: LoaderArgs) { 11 | const tenantId = requireTenantId(request) 12 | const userId = await requireUserId(request) 13 | invariant(params.noteId, 'noteId not found') 14 | 15 | const note = await getNote(tenantId, { userId, id: params.noteId }) 16 | if (!note) { 17 | throw new Response('Not Found', { status: 404 }) 18 | } 19 | return json({ note }) 20 | } 21 | 22 | export async function action({ request, params }: ActionArgs) { 23 | const tenantId = requireTenantId(request) 24 | const userId = await requireUserId(request) 25 | invariant(params.noteId, 'noteId not found') 26 | 27 | await deleteNote(tenantId, { userId, id: params.noteId }) 28 | 29 | return redirect('/notes') 30 | } 31 | 32 | export default function NoteDetailsPage() { 33 | const data = useLoaderData() 34 | 35 | return ( 36 |
37 |

{data.note.title}

38 |

{data.note.body}

39 |
40 |
41 | 47 |
48 |
49 | ) 50 | } 51 | 52 | export function ErrorBoundary({ error }: { error: Error }) { 53 | console.error(error) 54 | 55 | return
An unexpected error occurred: {error.message}
56 | } 57 | 58 | export function CatchBoundary() { 59 | const caught = useCatch() 60 | 61 | if (caught.status === 404) { 62 | return
Note not found
63 | } 64 | 65 | throw new Error(`Unexpected caught response with status: ${caught.status}`) 66 | } 67 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/routes/notes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@remix-run/react"; 2 | 3 | export default function NoteIndexPage() { 4 | return ( 5 |

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

11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /multi-tenant-prisma/app/routes/notes/new.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionArgs } from '@remix-run/node' 2 | import { json, redirect } from '@remix-run/node' 3 | import { Form, useActionData } from '@remix-run/react' 4 | import * as React from 'react' 5 | 6 | import { createNote } from '~/models/note.server' 7 | import { requireTenantId } from '~/models/tenant.server' 8 | import { requireUserId } from '~/session.server' 9 | 10 | export async function action({ request }: ActionArgs) { 11 | const tenantId = requireTenantId(request) 12 | const userId = await requireUserId(request) 13 | 14 | const formData = await request.formData() 15 | const title = formData.get('title') 16 | const body = formData.get('body') 17 | 18 | if (typeof title !== 'string' || title.length === 0) { 19 | return json( 20 | { errors: { title: 'Title is required', body: null } }, 21 | { status: 400 }, 22 | ) 23 | } 24 | 25 | if (typeof body !== 'string' || body.length === 0) { 26 | return json( 27 | { errors: { body: 'Body is required', title: null } }, 28 | { status: 400 }, 29 | ) 30 | } 31 | 32 | const note = await createNote(tenantId, { title, body, userId }) 33 | 34 | return redirect(`/notes/${note.id}`) 35 | } 36 | 37 | export default function NewNotePage() { 38 | const actionData = useActionData() 39 | const titleRef = React.useRef(null) 40 | const bodyRef = React.useRef(null) 41 | 42 | React.useEffect(() => { 43 | if (actionData?.errors?.title) { 44 | titleRef.current?.focus() 45 | } else if (actionData?.errors?.body) { 46 | bodyRef.current?.focus() 47 | } 48 | }, [actionData]) 49 | 50 | return ( 51 |
60 |
61 | 73 | {actionData?.errors?.title && ( 74 |
75 | {actionData.errors.title} 76 |
77 | )} 78 |
79 | 80 |
81 |