├── .env.example ├── scripts ├── dev-server │ ├── index.ts │ ├── vendor │ │ ├── parse-ms.ts │ │ ├── exit-hook.ts │ │ └── pretty-ms.ts │ ├── live-reload.ts │ └── serve.ts ├── compiler-webpack │ ├── index.ts │ ├── loaders │ │ ├── empty-module-loader.ts │ │ ├── remix-css-loader.ts │ │ └── browser-route-loader.ts │ ├── get-exports.ts │ ├── server-create-compiler.ts │ └── browser-create-compiler.ts ├── compiler-kit │ ├── index.ts │ ├── interface.ts │ ├── build.ts │ └── watch.ts ├── dev.ts ├── utils │ ├── channel.ts │ └── object.ts └── build.ts ├── app ├── contact.module.css ├── entry.client.tsx ├── routes │ ├── contacts │ │ ├── $contactId │ │ │ ├── destroy.tsx │ │ │ ├── edit.tsx │ │ │ └── index.tsx │ │ └── $contactId.tsx │ └── index.tsx ├── lib │ ├── error-page.tsx │ ├── db.server.ts │ └── contact.ts ├── entry.server.tsx ├── root.tsx └── index.css ├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png └── manifest.json ├── types ├── remix.env.d.ts └── module.css.d.ts ├── .gitignore ├── tsconfig.json ├── prisma ├── schema.prisma └── seed.ts ├── package.json ├── README.md ├── docs ├── remix-css-loader.md └── migration-guide.md ├── config.server.ts └── config.browser.ts /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="file:./data.db?connection_limit=1" -------------------------------------------------------------------------------- /scripts/dev-server/index.ts: -------------------------------------------------------------------------------- 1 | export { serve } from "./serve"; 2 | -------------------------------------------------------------------------------- /app/contact.module.css: -------------------------------------------------------------------------------- 1 | .title { 2 | background-color: red; 3 | } 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-webpack-demo/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-webpack-demo/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/remix-webpack-demo/HEAD/public/logo512.png -------------------------------------------------------------------------------- /types/remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /scripts/compiler-webpack/index.ts: -------------------------------------------------------------------------------- 1 | export { createBrowserCompiler } from "./browser-create-compiler"; 2 | export { createServerCompiler } from "./server-create-compiler"; 3 | -------------------------------------------------------------------------------- /types/module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.css" { 2 | export const link: { rel: "stylesheet"; href: string }; 3 | export const styles: Record; 4 | } 5 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { hydrateRoot } from "react-dom/client"; 3 | 4 | hydrateRoot(document, ); 5 | -------------------------------------------------------------------------------- /scripts/compiler-kit/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | BrowserCompiler, 3 | ServerCompiler, 4 | CreateCompiler, 5 | RemixCompiler, 6 | } from "./interface"; 7 | export { build } from "./build"; 8 | export { watch } from "./watch"; 9 | -------------------------------------------------------------------------------- /app/routes/contacts/$contactId/destroy.tsx: -------------------------------------------------------------------------------- 1 | import { ActionArgs, redirect } from "@remix-run/node"; 2 | 3 | import { deleteContact } from "~/lib/contact"; 4 | 5 | export async function action({ params }: ActionArgs) { 6 | await deleteContact(params.contactId!); 7 | return redirect("/"); 8 | } 9 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Index() { 2 | return ( 3 |

4 | This is a demo for React Router. 5 |
6 | Check out{" "} 7 | the docs at reactrouter.com. 8 |

9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /scripts/compiler-webpack/loaders/empty-module-loader.ts: -------------------------------------------------------------------------------- 1 | import type webpack from "webpack"; 2 | 3 | export default async function EmptyModuleLoader( 4 | this: webpack.LoaderContext 5 | ) { 6 | const callback = this.async(); 7 | this.cacheable(false); 8 | const emptyModule = "module.exports = {};"; 9 | callback(undefined, emptyModule); 10 | } 11 | -------------------------------------------------------------------------------- /app/lib/error-page.tsx: -------------------------------------------------------------------------------- 1 | export default function ErrorPage({ 2 | error, 3 | }: { 4 | error: { message?: string; statusText?: string }; 5 | }) { 6 | console.error(error); 7 | 8 | return ( 9 |
10 |

Oops!

11 |

Sorry, an unexpected error has occurred.

12 |

13 | {error.statusText || error.message} 14 |

15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /scripts/dev.ts: -------------------------------------------------------------------------------- 1 | import { readConfig } from "@remix-run/dev/dist/config.js"; 2 | import { serve } from "./dev-server"; 3 | import { 4 | createBrowserCompiler, 5 | createServerCompiler, 6 | } from "./compiler-webpack"; 7 | 8 | async function command() { 9 | let remixConfig = await readConfig(); 10 | serve(remixConfig, "development", { 11 | browser: createBrowserCompiler, 12 | server: createServerCompiler, 13 | }); 14 | } 15 | 16 | command(); 17 | -------------------------------------------------------------------------------- /app/routes/contacts/$contactId.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet, useCatch } from "@remix-run/react"; 2 | import ErrorPage from "~/lib/error-page"; 3 | 4 | export default function ContactId() { 5 | return ; 6 | } 7 | 8 | export function CatchBoundary() { 9 | const caught = useCatch(); 10 | return ; 11 | } 12 | 13 | export function ErrorBoundary({ 14 | error, 15 | }: { 16 | error: Error & { statusText?: string }; 17 | }) { 18 | return ; 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # remix 26 | /.cache/ 27 | /public/build/ 28 | 29 | # prisma 30 | /prisma/*.db 31 | .env 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "resolveJsonModule": true, 9 | "moduleResolution": "node", 10 | "baseUrl": ".", 11 | "noEmit": true, 12 | "paths": { 13 | "~/*": ["./app/*"] 14 | }, 15 | "allowJs": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "strict": true, 18 | "target": "ES2019" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model Contact { 14 | id String @id @default(uuid()) 15 | createdAt DateTime @default(now()) 16 | first String? 17 | last String? 18 | favorite Boolean @default(false) 19 | avatar String? 20 | twitter String? 21 | notes String? 22 | } -------------------------------------------------------------------------------- /scripts/utils/channel.ts: -------------------------------------------------------------------------------- 1 | export interface WriteChannel { 2 | write: (data: T) => void; 3 | } 4 | export interface ReadChannel { 5 | read: () => Promise; 6 | } 7 | export type Channel = WriteChannel & ReadChannel; 8 | 9 | export const createChannel = (): Channel => { 10 | let promiseResolve: ((value: T) => void) | undefined = undefined; 11 | 12 | const promise = new Promise((resolve) => { 13 | promiseResolve = resolve; 14 | }); 15 | 16 | return { 17 | write: promiseResolve!, 18 | read: async () => promise, 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import { readConfig } from "@remix-run/dev/dist/config.js"; 2 | import { 3 | createBrowserCompiler, 4 | createServerCompiler, 5 | } from "./compiler-webpack"; 6 | import { build } from "./compiler-kit"; 7 | 8 | async function command() { 9 | console.time("Remix Compile"); 10 | let remixConfig = await readConfig(); 11 | let compiler = { 12 | browser: createBrowserCompiler(remixConfig), 13 | server: createServerCompiler(remixConfig), 14 | }; 15 | await build(remixConfig, compiler); 16 | console.timeEnd("Remix Compile"); 17 | } 18 | 19 | command(); 20 | -------------------------------------------------------------------------------- /app/lib/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | let db: PrismaClient; 4 | 5 | declare global { 6 | var __db: PrismaClient | undefined; 7 | } 8 | 9 | // this is needed because in development we don't want to restart 10 | // the server with every change, but we want to make sure we don't 11 | // create a new connection to the DB with every change either. 12 | if (process.env.NODE_ENV === "production") { 13 | db = new PrismaClient(); 14 | } else { 15 | if (!global.__db) { 16 | global.__db = new PrismaClient(); 17 | } 18 | db = global.__db; 19 | } 20 | 21 | export { db }; 22 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /scripts/dev-server/vendor/parse-ms.ts: -------------------------------------------------------------------------------- 1 | export default function parseMilliseconds(milliseconds: number) { 2 | if (typeof milliseconds !== "number") { 3 | throw new TypeError("Expected a number"); 4 | } 5 | 6 | return { 7 | days: Math.trunc(milliseconds / 86400000), 8 | hours: Math.trunc(milliseconds / 3600000) % 24, 9 | minutes: Math.trunc(milliseconds / 60000) % 60, 10 | seconds: Math.trunc(milliseconds / 1000) % 60, 11 | milliseconds: Math.trunc(milliseconds) % 1000, 12 | microseconds: Math.trunc(milliseconds * 1000) % 1000, 13 | nanoseconds: Math.trunc(milliseconds * 1e6) % 1000, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { EntryContext } from "@remix-run/node"; 2 | import { RemixServer } from "@remix-run/react"; 3 | import { renderToString } from "react-dom/server"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | let markup = renderToString( 12 | 13 | ); 14 | responseHeaders.set("Content-Type", "text/html"); 15 | return new Response("" + markup, { 16 | status: responseStatusCode, 17 | headers: responseHeaders, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /scripts/utils/object.ts: -------------------------------------------------------------------------------- 1 | type Entry = { 2 | [K in keyof Obj]: [K, Obj[K]]; 3 | }[keyof Obj]; 4 | export const entries = (obj: Obj): Entry[] => { 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | return Object.entries(obj) as any; 7 | }; 8 | 9 | type FromEntries = { 10 | [E in Entry as E[0]]: E[1]; 11 | }; 12 | export const fromEntries = < 13 | Entries extends (readonly [PropertyKey, unknown])[] 14 | >( 15 | entries: Entries 16 | ): FromEntries => { 17 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 18 | return Object.fromEntries(entries) as any; 19 | }; 20 | -------------------------------------------------------------------------------- /scripts/compiler-kit/interface.ts: -------------------------------------------------------------------------------- 1 | import type { AssetsManifest } from "@remix-run/dev/dist/compiler/assets"; 2 | import { RemixConfig } from "@remix-run/dev/dist/config"; 3 | 4 | import { ReadChannel, WriteChannel } from "../utils/channel"; 5 | 6 | export interface BrowserCompiler { 7 | // produce ./public/build/ 8 | build: (manifestChannel: WriteChannel) => Promise; 9 | dispose: () => void; 10 | } 11 | export interface ServerCompiler { 12 | // produce ./build/index.js 13 | build: (manifestChannel: ReadChannel) => Promise; 14 | dispose: () => void; 15 | } 16 | export type CreateCompiler = ( 17 | config: RemixConfig 18 | ) => T; 19 | 20 | export interface RemixCompiler { 21 | browser: BrowserCompiler; 22 | server: ServerCompiler; 23 | } 24 | -------------------------------------------------------------------------------- /scripts/compiler-webpack/get-exports.ts: -------------------------------------------------------------------------------- 1 | import os from "os"; 2 | 3 | import esbuild from "esbuild"; 4 | import { RemixConfig } from "@remix-run/dev/dist/config"; 5 | 6 | export function getExports( 7 | routePath: string, 8 | remixConfig: RemixConfig 9 | ): string[] { 10 | const { metafile, errors } = esbuild.buildSync({ 11 | sourceRoot: remixConfig.appDirectory, 12 | entryPoints: [routePath], 13 | target: "esnext", 14 | bundle: false, 15 | metafile: true, 16 | write: false, 17 | outdir: os.tmpdir(), 18 | }); 19 | 20 | if (errors?.length > 0) { 21 | throw new Error( 22 | esbuild.formatMessagesSync(errors, { kind: "error" }).join("\n") 23 | ); 24 | } 25 | 26 | const outputs = Object.values(metafile!.outputs); 27 | if (outputs.length !== 1) { 28 | throw Error(); 29 | } 30 | const output = outputs[0]; 31 | return output.exports; 32 | } 33 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | const db = new PrismaClient(); 3 | 4 | type CreateContact = Parameters[0]["data"]; 5 | 6 | const contacts: CreateContact[] = [ 7 | { first: "Dennis", last: "Beatty" }, 8 | { first: "Greg", last: "Brimble" }, 9 | { first: "Ryan", last: "Dahl" }, 10 | { first: "Sarah", last: "Dayan" }, 11 | { first: "Ceora", last: "Ford" }, 12 | { first: "Anthony", last: "Frehner" }, 13 | { first: "Ariza", last: "Fukuzaki" }, 14 | { 15 | first: "Henri", 16 | last: "Helvetica", 17 | avatar: 18 | "https://pbs.twimg.com/profile_images/960605708202004481/MMNCgNgM_400x400.jpg", 19 | twitter: "@HenriHelvetica", 20 | favorite: true, 21 | notes: "How To WebPageTest", 22 | }, 23 | { first: "Michael", last: "Jackson" }, 24 | ]; 25 | 26 | async function seed() { 27 | await Promise.all( 28 | contacts.map((contact) => { 29 | return db.contact.create({ data: contact }); 30 | }) 31 | ); 32 | } 33 | 34 | seed(); 35 | -------------------------------------------------------------------------------- /scripts/dev-server/vendor/exit-hook.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | 3 | const callbacks = new Set<() => unknown>(); 4 | let isCalled = false; 5 | let isRegistered = false; 6 | 7 | function exit(shouldManuallyExit: boolean, signal: number) { 8 | if (isCalled) { 9 | return; 10 | } 11 | 12 | isCalled = true; 13 | 14 | for (const callback of callbacks) { 15 | callback(); 16 | } 17 | 18 | if (shouldManuallyExit === true) { 19 | process.exit(128 + signal); 20 | } 21 | } 22 | 23 | export default function exitHook(onExit: () => void) { 24 | callbacks.add(onExit); 25 | 26 | if (!isRegistered) { 27 | isRegistered = true; 28 | 29 | process.once("exit", exit); 30 | process.once("SIGINT", exit.bind(undefined, true, 2)); 31 | process.once("SIGTERM", exit.bind(undefined, true, 15)); 32 | 33 | // PM2 Cluster shutdown message. Caught to support async handlers with pm2, needed because 34 | // explicitly calling process.exit() doesn't trigger the beforeExit event, and the exit 35 | // event cannot support async handlers, since the event loop is never called after it. 36 | process.on("message", (message) => { 37 | if (message === "shutdown") { 38 | exit(true, -128); 39 | } 40 | }); 41 | } 42 | 43 | return () => { 44 | callbacks.delete(onExit); 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /scripts/compiler-kit/build.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | import { AssetsManifest } from "@remix-run/dev/dist/compiler/assets"; 5 | import { RemixConfig } from "@remix-run/dev/dist/config"; 6 | 7 | import { createChannel } from "../utils/channel"; 8 | import type { RemixCompiler } from "./interface"; 9 | 10 | // TODO error handling for if browser/server builds fail (e.g. syntax error) 11 | // enumerate different types of errors 12 | // console.log hints for users if we know how to diagnose the error from experience 13 | // consider throwing custom Remix-specific error type if its an error we know more stuff about 14 | 15 | /** 16 | * Coordinate the hand-off of the asset manifest between the browser and server builds. 17 | * Additionally, write the asset manifest to the file system. 18 | */ 19 | export const build = async ( 20 | config: RemixConfig, 21 | compiler: RemixCompiler 22 | ): Promise => { 23 | let manifestChannel = createChannel(); 24 | let browser = compiler.browser.build(manifestChannel); 25 | 26 | // write manifest 27 | manifestChannel.read().then((manifest) => { 28 | fs.mkdirSync(config.assetsBuildDirectory, { recursive: true }); 29 | fs.writeFileSync( 30 | path.resolve(config.assetsBuildDirectory, path.basename(manifest.url!)), 31 | `window.__remixManifest=${JSON.stringify(manifest)};` 32 | ); 33 | }); 34 | 35 | let server = compiler.server.build(manifestChannel); 36 | await Promise.all([browser, server]); 37 | }; 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "dependencies": { 4 | "@prisma/client": "^4.5.0", 5 | "@remix-run/node": "^1.13.0", 6 | "@remix-run/react": "^1.13.0", 7 | "@remix-run/serve": "^1.13.0", 8 | "@types/node": "^16.11.66", 9 | "@types/react": "^18.0.21", 10 | "@types/react-dom": "^18.0.6", 11 | "react": "^18.2.0", 12 | "react-dom": "^18.2.0", 13 | "react-router-dom": "^6.4.2", 14 | "typescript": "^4.8.4" 15 | }, 16 | "scripts": { 17 | "build": "ts-node ./scripts/build.ts", 18 | "start": "remix-serve build", 19 | "dev": "NODE_ENV=development ts-node ./scripts/dev.ts" 20 | }, 21 | "prisma": { 22 | "seed": "ts-node prisma/seed.ts" 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | }, 36 | "devDependencies": { 37 | "@remix-run/dev": "^1.13.0", 38 | "@types/express": "^4.17.14", 39 | "@types/fs-extra": "^9.0.13", 40 | "@types/loader-utils": "^2.0.3", 41 | "@types/webpack-node-externals": "^2.5.3", 42 | "@types/ws": "^8.5.3", 43 | "css-loader": "^6.7.1", 44 | "dotenv": "^16.0.3", 45 | "esbuild": "^0.15.12", 46 | "esbuild-loader": "^2.20.0", 47 | "express": "^4.18.2", 48 | "fs-extra": "^10.1.0", 49 | "loader-utils": "^3.2.0", 50 | "prisma": "^4.5.0", 51 | "ts-node": "^10.9.1", 52 | "webpack": "^5.74.0", 53 | "webpack-node-externals": "^3.0.0", 54 | "webpack-virtual-modules": "^0.4.5", 55 | "ws": "^8.9.0" 56 | }, 57 | "prettier": {} 58 | } 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix Webpack Demo 2 | 3 | This is an example of using Webpack as the compiler for a Remix app. While not supported as a long-term solution, we offer this example as a migration path for your React Router / Webpack apps to Remix. 4 | 5 | To migrate your Webpack-based React Router app, **check out the [migration guide](./docs/migration-guide.md)**. 👀 6 | 7 | --- 8 | 9 | How this repo was made: 10 | 11 | 1. Create a new project with [Create React App](https://create-react-app.dev/) 12 | 2. 👉 Implement the [React Router v6.4 tutorial](https://reactrouter.com/en/main/start/tutorial) 13 | 3. 🚚 [Migrate to Remix](https://remix.run/docs/en/v1/guides/migrating-react-router-app) 14 | 4. Replace standard Remix dev tools with Webpack-based compiler found in `./scripts` 15 | 16 | The commit history includes 👉 and 🚚 emojis so you can follow along with which commits came from which step. 17 | 18 | ## Setup 19 | 20 | ### 1 Install 21 | 22 | ```sh 23 | npm install 24 | ``` 25 | 26 | ### 2 `.env` 27 | 28 | Copy `.env.example` as `.env`: 29 | 30 | ```sh 31 | DATABASE_URL="file:./dev.db" 32 | ``` 33 | 34 | ### 3 Initialize the database 35 | 36 | ```sh 37 | npx prisma db push 38 | ``` 39 | 40 | If you want some data for development, seed the database: 41 | 42 | ```sh 43 | npx prisma db seed 44 | ``` 45 | 46 | ### 4 Run the app 47 | 48 | ```sh 49 | # development 50 | npm run dev 51 | 52 | # production build 53 | npm run build 54 | npm start 55 | ``` 56 | 57 | ## Configuration 58 | 59 | Webpack configs can be found at: 60 | 61 | - [`./config.browser.ts`](./config.browser.ts) 62 | - [`./config.server.ts`](./config.server.ts) 63 | 64 | You can add loaders or plugins there to add support for any features you'd like from Webpack! 65 | 66 | For example, you could install [`postcss-loader`](https://webpack.js.org/loaders/postcss-loader/) and add it to both the browser and server configs to get [PostCSS](https://postcss.org/) features! 67 | -------------------------------------------------------------------------------- /app/lib/contact.ts: -------------------------------------------------------------------------------- 1 | import { db } from "~/lib/db.server"; 2 | 3 | export type { Contact } from "@prisma/client"; 4 | 5 | function matchCaseInsensitive(query: string, text?: string) { 6 | return text && text.toLocaleLowerCase().includes(query); 7 | } 8 | 9 | export async function getContacts(query?: string) { 10 | await fakeNetwork(`getContacts:${query}`); 11 | let contacts = await db.contact.findMany({ 12 | orderBy: [{ first: "asc" }, { last: "asc" }], 13 | }); 14 | return query 15 | ? contacts.filter( 16 | (contact) => 17 | matchCaseInsensitive(query, contact.first ?? undefined) || 18 | matchCaseInsensitive(query, contact.last ?? undefined) 19 | ) 20 | : contacts; 21 | } 22 | 23 | export async function createContact() { 24 | await fakeNetwork(); 25 | let contact = await db.contact.create({ data: {} }); 26 | return contact; 27 | } 28 | 29 | export async function getContact(id: string) { 30 | await fakeNetwork(`contact:${id}`); 31 | let contact = await db.contact.findUnique({ where: { id } }); 32 | return contact; 33 | } 34 | 35 | export async function updateContact( 36 | id: string, 37 | updates: Parameters[0]["data"] 38 | ) { 39 | await fakeNetwork(); 40 | let contact = await db.contact.update({ where: { id }, data: updates }); 41 | return contact; 42 | } 43 | 44 | export async function deleteContact(id: string) { 45 | let contact = await db.contact.delete({ where: { id } }); 46 | return contact; 47 | } 48 | 49 | // fake a cache so we don't slow down stuff we've already seen 50 | let fakeCache: Record = {}; 51 | 52 | async function fakeNetwork(key?: string) { 53 | if (!key) { 54 | fakeCache = {}; 55 | return; 56 | } 57 | if (fakeCache[key]) { 58 | return; 59 | } 60 | 61 | fakeCache[key] = true; 62 | return new Promise((res) => { 63 | setTimeout(res, Math.random() * 800); 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /scripts/compiler-webpack/loaders/remix-css-loader.ts: -------------------------------------------------------------------------------- 1 | import { interpolateName } from "loader-utils"; 2 | import webpack from "webpack"; 3 | 4 | type Exports = string[][] & { locals?: Record }; 5 | 6 | const getPublicPath = (loaderContext: webpack.LoaderContext) => { 7 | let { publicPath } = loaderContext._compilation!.outputOptions; 8 | if (typeof publicPath !== "string") { 9 | throw Error("Public path must be a string"); 10 | } 11 | return publicPath; 12 | }; 13 | 14 | const getCssChunkFilename = (loaderContext: webpack.LoaderContext) => { 15 | let { cssChunkFilename, assetModuleFilename } = 16 | loaderContext._compilation!.outputOptions; 17 | return typeof cssChunkFilename === "string" 18 | ? cssChunkFilename 19 | : typeof assetModuleFilename === "string" 20 | ? assetModuleFilename 21 | : "_assets/[name]-[contenthash][ext]"; 22 | }; 23 | 24 | export async function pitch( 25 | this: webpack.LoaderContext<{ emit: boolean }>, 26 | source: string | Buffer 27 | ) { 28 | let callback = this.async(); 29 | let options = this.getOptions(); 30 | 31 | let originalExports = (await this.importModule( 32 | this.resourcePath + ".webpack[javascript/auto]" + "!=!" + source 33 | )) as 34 | | { __esModule: true; default: Exports } 35 | | ({ __esModule: false } & Exports); 36 | 37 | let exports = originalExports.__esModule 38 | ? originalExports.default 39 | : originalExports; 40 | 41 | let css = exports[0].slice(1).join("\n"); 42 | let assetPath = interpolateName(this, getCssChunkFilename(this), { 43 | content: css, 44 | }); 45 | 46 | let result = ` 47 | export const link = { 48 | rel: "stylesheet", 49 | href: ${JSON.stringify(getPublicPath(this) + assetPath)}, 50 | } 51 | `; 52 | 53 | let classNamesMap = exports.locals; 54 | if (classNamesMap !== undefined) { 55 | result += `\nexport const styles = ${JSON.stringify(classNamesMap)}`; 56 | } 57 | 58 | // TODO: sourcemaps? 59 | if (options.emit) { 60 | this.emitFile(assetPath, css); 61 | } 62 | 63 | return callback(undefined, result); 64 | } 65 | -------------------------------------------------------------------------------- /docs/remix-css-loader.md: -------------------------------------------------------------------------------- 1 | # remix-css-loader 2 | 3 | [This loader](../scripts/compiler-webpack/loaders/remix-css-loader.ts) adapts the output of `css-loader` so that it is Remix compatible. 4 | 5 | You should probably use it any time that you use `css-loader` (instead of simply using `type: "asset/resource'`) in your Webpack config. 6 | 7 | It does so by importing the JS module produced by `css-loader` which contains exports for the transpiled CSS as well as the mapping for user-facing classnames to their hashed counterparts. 8 | 9 | It then computes then writes the transpiled CSS to the configured output location. 10 | 11 | Finally, it resolves `*.module.css` as: 12 | 13 | ```ts 14 | export const link: { rel: "stylesheet"; href: string }; 15 | export const styles: Record; 16 | ``` 17 | 18 | ## Example: CSS Modules 19 | 20 | For [CSS Modules](https://github.com/css-modules/css-modules), first add `css-loader` and `remix-css-loader` to your config: 21 | 22 | ```ts 23 | { 24 | module: { 25 | rules: [ 26 | { 27 | // handle CSS Modules 28 | test: /\.module\.css$/i, 29 | use: [ 30 | { 31 | loader: require.resolve("./loaders/remix-css-loader.ts"), 32 | // emit the CSS for the browser build 33 | // set `emit: false` for the server build 34 | options: { emit: true }, 35 | }, 36 | { 37 | loader: "css-loader", 38 | options: { modules: true }, 39 | }, 40 | ], 41 | }, 42 | { 43 | // handle normal CSS 44 | test: /\.css$/i, 45 | type: "asset/resource", 46 | exclude: /\.module\.css$/i, 47 | }, 48 | ] 49 | } 50 | } 51 | ``` 52 | 53 | ### Usage 54 | 55 | ```css 56 | /* my.module.css */ 57 | 58 | .title { 59 | background-color: red; 60 | } 61 | ``` 62 | 63 | ```ts 64 | import { link as cssLink, styles } from "./path/to/my.module.css" 65 | 66 | // add `link` to your route's links 67 | export const links = () => [cssLink] 68 | 69 | export default function Route() { 70 | // use `styles` as a normal CSS Modules import 71 | return

Hello world!

72 | } 73 | ``` -------------------------------------------------------------------------------- /scripts/compiler-webpack/loaders/browser-route-loader.ts: -------------------------------------------------------------------------------- 1 | import type { RemixConfig } from "@remix-run/dev/dist/config"; 2 | import esbuild from "esbuild"; 3 | import type webpack from "webpack"; 4 | 5 | import { getExports } from "../get-exports"; 6 | 7 | const BROWSER_EXPORTS = [ 8 | "CatchBoundary", 9 | "ErrorBoundary", 10 | "default", 11 | "handle", 12 | "links", 13 | "meta", 14 | "unstable_shouldReload", 15 | ] as const; 16 | 17 | async function treeshakeBrowserExports( 18 | routePath: string, 19 | remixConfig: RemixConfig 20 | ): Promise { 21 | const xports = getExports(routePath, remixConfig); 22 | const browserExports = xports.filter((xport) => 23 | (BROWSER_EXPORTS as unknown as string[]).includes(xport) 24 | ); 25 | 26 | let virtualModule = "module.exports = {};"; 27 | if (browserExports.length !== 0) { 28 | virtualModule = `export { ${browserExports.join( 29 | ", " 30 | )} } from "${routePath}";`; 31 | } 32 | 33 | const { outputFiles } = await esbuild.build({ 34 | stdin: { contents: virtualModule, resolveDir: remixConfig.rootDirectory }, 35 | format: "esm", 36 | target: "es2018", 37 | treeShaking: true, 38 | write: false, 39 | sourcemap: "inline", 40 | bundle: true, 41 | plugins: [ 42 | { 43 | name: "externals", 44 | setup(build) { 45 | build.onResolve({ filter: /.*/ }, (args) => { 46 | if (args.path === routePath) return undefined; 47 | return { external: true, sideEffects: false }; 48 | }); 49 | }, 50 | }, 51 | ], 52 | }); 53 | return outputFiles[0].text; 54 | } 55 | 56 | export default async function BrowserRoutesLoader( 57 | this: webpack.LoaderContext<{ 58 | remixConfig: RemixConfig; 59 | browserRouteRegex: RegExp; 60 | }> 61 | ) { 62 | const callback = this.async(); 63 | this.cacheable(false); 64 | const { remixConfig, browserRouteRegex } = this.getOptions(); 65 | const routePath = this.resource.replace(browserRouteRegex, "/"); 66 | const browserRouteVirtualModule = await treeshakeBrowserExports( 67 | routePath, 68 | remixConfig 69 | ); 70 | callback(undefined, browserRouteVirtualModule); 71 | } 72 | -------------------------------------------------------------------------------- /scripts/dev-server/live-reload.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | import * as fse from "fs-extra"; 4 | import WebSocket from "ws"; 5 | 6 | import { type RemixConfig } from "@remix-run/dev/dist/config"; 7 | import { 8 | type BrowserCompiler, 9 | type CreateCompiler, 10 | type ServerCompiler, 11 | watch, 12 | } from "../compiler-kit"; 13 | 14 | import exitHook from "./vendor/exit-hook"; 15 | import prettyMs from "./vendor/pretty-ms"; 16 | import { createChannel } from "../utils/channel"; 17 | 18 | const relativePath = (file: string) => path.relative(process.cwd(), file); 19 | 20 | export async function liveReload( 21 | config: RemixConfig, 22 | createCompiler: { 23 | browser: CreateCompiler; 24 | server: CreateCompiler; 25 | }, 26 | callbacks: { 27 | onInitialBuild?: () => void; 28 | } = {} 29 | ) { 30 | const wss = new WebSocket.Server({ port: config.devServerPort }); 31 | function broadcast(event: { type: string } & Record) { 32 | setTimeout(() => { 33 | wss.clients.forEach((client) => { 34 | if (client.readyState === WebSocket.OPEN) { 35 | client.send(JSON.stringify(event)); 36 | } 37 | }); 38 | }, config.devServerBroadcastDelay); 39 | } 40 | function log(message: string) { 41 | message = `💿 ${message}`; 42 | console.log(message); 43 | broadcast({ type: "LOG", message }); 44 | } 45 | 46 | const dispose = await watch(config, createCompiler, { 47 | onInitialBuild: callbacks.onInitialBuild, 48 | onRebuildStart() { 49 | log("Rebuilding..."); 50 | }, 51 | onRebuildFinish(durationMs: number) { 52 | log(`Rebuilt in ${prettyMs(durationMs)}`); 53 | broadcast({ type: "RELOAD" }); 54 | }, 55 | onFileCreated(file) { 56 | log(`File created: ${relativePath(file)}`); 57 | }, 58 | onFileChanged(file) { 59 | log(`File changed: ${relativePath(file)}`); 60 | }, 61 | onFileDeleted(file) { 62 | log(`File deleted: ${relativePath(file)}`); 63 | }, 64 | }); 65 | 66 | const channel = createChannel(); 67 | exitHook(async () => { 68 | wss.close(); 69 | await dispose(); 70 | fse.emptyDirSync(config.assetsBuildDirectory); 71 | fse.rmSync(config.serverBuildPath); 72 | channel.write(); 73 | }); 74 | await channel.read(); 75 | } 76 | -------------------------------------------------------------------------------- /app/routes/contacts/$contactId/edit.tsx: -------------------------------------------------------------------------------- 1 | import { ActionArgs, LoaderArgs, redirect } from "@remix-run/node"; 2 | import { Form, useLoaderData, useNavigate } from "@remix-run/react"; 3 | 4 | import { getContact, updateContact } from "~/lib/contact"; 5 | 6 | export async function loader({ params }: LoaderArgs) { 7 | const contact = await getContact(params.contactId!); 8 | if (!contact) { 9 | throw new Response("", { 10 | status: 404, 11 | statusText: "Not Found", 12 | }); 13 | } 14 | return contact; 15 | } 16 | 17 | export async function action({ request, params }: ActionArgs) { 18 | const formData = await request.formData(); 19 | const updates = Object.fromEntries(formData); 20 | await updateContact(params.contactId!, updates); 21 | return redirect(`/contacts/${params.contactId}`); 22 | } 23 | 24 | export default function EditContact() { 25 | const contact = useLoaderData(); 26 | const navigate = useNavigate(); 27 | 28 | return ( 29 |
30 |

31 | Name 32 | 39 | 46 |

47 | 56 | 66 |