├── .gitignore ├── example └── remix-vite-express │ ├── .gitignore │ ├── app │ ├── hello.server.ts │ ├── routes │ │ ├── test-redirect.tsx │ │ ├── test-error.tsx │ │ ├── _protected.dashboard.tsx │ │ ├── logout.tsx │ │ ├── login.tsx │ │ ├── _protected.tsx │ │ ├── _protected.dashboard._index.tsx │ │ ├── _index.tsx │ │ └── test.tsx │ ├── types │ │ ├── session.d.ts │ │ └── context.d.ts │ ├── entry.client.tsx │ ├── middleware │ │ ├── requireAuth.ts │ │ └── session.ts │ ├── root.tsx │ └── entry.server.tsx │ ├── public │ └── favicon.ico │ ├── vite.config.ts │ ├── README.md │ ├── tsconfig.json │ ├── package.json │ └── .eslintrc.cjs ├── .prettierrc ├── packages ├── remix-express-dev-server │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── package.json │ ├── CHANGELOG.md │ ├── README.md │ └── src │ │ └── index.ts └── remix-create-express-app │ ├── types │ └── remix-run-dev.d.ts │ ├── tsconfig.json │ ├── tsconfig.build.json │ ├── src │ ├── routes.ts │ ├── context.ts │ ├── remix.ts │ ├── middleware.ts │ └── index.ts │ ├── .all-contributorsrc │ ├── package.json │ ├── CHANGELOG.md │ ├── tests │ └── middleware.ts │ └── README.md ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | tmp 4 | *.tgz 5 | example/remix-test -------------------------------------------------------------------------------- /example/remix-vite-express/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | .env 6 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/hello.server.ts: -------------------------------------------------------------------------------- 1 | export const sayHello = () => 'hello world' 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "arrowParens": "avoid" 6 | } -------------------------------------------------------------------------------- /example/remix-vite-express/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiliman/remix-express-vite-plugin/HEAD/example/remix-vite-express/public/favicon.ico -------------------------------------------------------------------------------- /packages/remix-express-dev-server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false 5 | }, 6 | "include": ["src/**/*"], 7 | "exclude": ["**/*.test.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/routes/test-redirect.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect, type LoaderFunctionArgs } from '@remix-run/node' 2 | 3 | export async function loader({ request }: LoaderFunctionArgs) { 4 | throw redirect('/') 5 | } 6 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/routes/test-error.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect, type LoaderFunctionArgs } from '@remix-run/node' 2 | 3 | export async function loader({ request }: LoaderFunctionArgs) { 4 | throw new Error('Oops!') 5 | } 6 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/types/session.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | type SessionData = { 3 | count: number 4 | userId: string 5 | } 6 | 7 | type SessionFlashData = { 8 | error: string 9 | } 10 | } 11 | 12 | export {} 13 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/types/context.d.ts: -------------------------------------------------------------------------------- 1 | import { ServerContext } from 'remix-create-express-app/context' 2 | 3 | declare module '@remix-run/server-runtime' { 4 | export interface AppLoadContext extends ServerContext { 5 | sayHello: () => string 6 | } 7 | } 8 | 9 | export {} 10 | -------------------------------------------------------------------------------- /packages/remix-create-express-app/types/remix-run-dev.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@remix-run/dev/dist/vite/plugin.js' { 2 | export function setRemixDevLoadContext( 3 | loadContext: (request: Request) => MaybePromise>, 4 | ): void 5 | } 6 | type MaybePromise = T | Promise 7 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/routes/_protected.dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderFunctionArgs } from '@remix-run/node' 2 | import { Form, Outlet } from '@remix-run/react' 3 | 4 | export async function loader({ context }: LoaderFunctionArgs) { 5 | return null 6 | } 7 | 8 | export default function Component() { 9 | return ( 10 |
11 |

Dashboard

12 | 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-express-vite-plugin", 3 | "version": "0.1.0", 4 | "description": "A Vite plugin for Remix and Express", 5 | "main": "index.js", 6 | "workspaces": [ 7 | "packages/*", 8 | "example/*" 9 | ], 10 | "directories": { 11 | "example": "example" 12 | }, 13 | "scripts": { 14 | "test": "echo \"Error: no test specified\" && exit 1" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC" 19 | } 20 | -------------------------------------------------------------------------------- /packages/remix-express-dev-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 4 | "esModuleInterop": true, 5 | "moduleResolution": "Node", 6 | "target": "ES2022", 7 | "module": "ES2022", 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "skipLibCheck": true, 11 | "declaration": true, 12 | "noEmit": true 13 | }, 14 | "exclude": ["node_modules"], 15 | "include": ["src/**/*.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /packages/remix-create-express-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 4 | "esModuleInterop": true, 5 | "moduleResolution": "Node", 6 | "target": "ES2022", 7 | "module": "ES2022", 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "allowImportingTsExtensions": true, 11 | "skipLibCheck": true, 12 | "declaration": true, 13 | "noEmit": true 14 | }, 15 | "exclude": ["node_modules"], 16 | "include": ["src/**/*.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /packages/remix-create-express-app/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 4 | "esModuleInterop": true, 5 | "moduleResolution": "Node", 6 | "target": "ES2022", 7 | "module": "ES2022", 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "allowImportingTsExtensions": true, 11 | "skipLibCheck": true, 12 | "declaration": true, 13 | "emitDeclarationOnly": true 14 | }, 15 | "exclude": ["node_modules"], 16 | "include": ["src/**/*.ts", "types/**/*.d.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import { type ActionFunctionArgs, redirect } from '@remix-run/node' 2 | import { Form, Link } from '@remix-run/react' 3 | 4 | export async function action({ request }: ActionFunctionArgs) { 5 | throw redirect('/', { headers: { 'Set-Cookie': `user=; Max-Age=0` } }) 6 | } 7 | 8 | export default function Component() { 9 | return ( 10 |
11 |

Login

12 | Home 13 |
14 | 15 | 16 |
17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /example/remix-vite-express/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from '@remix-run/dev' 2 | import { installGlobals } from '@remix-run/node' 3 | import { defineConfig } from 'vite' 4 | import tsconfigPaths from 'vite-tsconfig-paths' 5 | import { expressDevServer } from 'remix-express-dev-server' 6 | import envOnly from 'vite-env-only' 7 | 8 | installGlobals({ nativeFetch: true }) 9 | 10 | export default defineConfig({ 11 | build: { 12 | target: 'esnext', 13 | }, 14 | plugins: [ 15 | expressDevServer(), 16 | envOnly(), 17 | remix({ 18 | future: { unstable_singleFetch: true }, 19 | }), 20 | tsconfigPaths(), 21 | ], 22 | }) 23 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { startTransition, StrictMode } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /example/remix-vite-express/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix + Vite! 2 | 3 | 📖 See the [Remix docs](https://remix.run/docs) and the [Remix Vite docs](https://remix.run/docs/en/main/guides/vite) for details on supported features. 4 | 5 | ## Development 6 | 7 | Run the Vite dev server: 8 | 9 | ```shellscript 10 | npm run dev 11 | ``` 12 | 13 | ## Deployment 14 | 15 | First, build your app for production: 16 | 17 | ```sh 18 | npm run build 19 | ``` 20 | 21 | Then run the app in production mode: 22 | 23 | ```sh 24 | npm start 25 | ``` 26 | 27 | Now you'll need to pick a host to deploy it to. 28 | 29 | ### DIY 30 | 31 | If you're familiar with deploying Node applications, the built-in Remix app server is production-ready. 32 | 33 | Make sure to deploy the output of `npm run build` 34 | 35 | - `build/server` 36 | - `build/client` 37 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import { type ActionFunctionArgs, redirect } from '@remix-run/node' 2 | import { Form, Link } from '@remix-run/react' 3 | 4 | export async function action({ request }: ActionFunctionArgs) { 5 | const url = new URL(request.url) 6 | const formData = await request.formData() 7 | const name = formData.get('name') 8 | 9 | const redirectUrl = url.searchParams.get('redirectTo') ?? '/' 10 | throw redirect(redirectUrl, { headers: { 'Set-Cookie': `user=${name}` } }) 11 | } 12 | 13 | export default function Component() { 14 | return ( 15 |
16 |

Login

17 | Home 18 |
19 | 20 | 21 |
22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/middleware/requireAuth.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@remix-run/node' 2 | import { type MiddlewareFunctionArgs } from 'remix-create-express-app/middleware' 3 | import { createContext } from 'remix-create-express-app/context' 4 | import cookie from 'cookie' 5 | 6 | export type User = { 7 | name: string 8 | } 9 | 10 | export const UserContext = createContext() 11 | 12 | export async function requireAuth({ 13 | request, 14 | context, 15 | next, 16 | }: MiddlewareFunctionArgs) { 17 | // get user cookie 18 | const cookies = cookie.parse(request.headers.get('Cookie') ?? '') 19 | if (!cookies.user) { 20 | const url = new URL(request.url) 21 | throw redirect(`/login?redirectTo=${encodeURI(url.pathname + url.search)}`) 22 | } 23 | // set the user in the context from the cookie 24 | context.set(UserContext, { name: cookies.user }) 25 | return next() 26 | } 27 | -------------------------------------------------------------------------------- /example/remix-vite-express/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*.ts", 4 | "**/*.tsx", 5 | "**/.server/**/*.ts", 6 | "**/.server/**/*.tsx", 7 | "**/.client/**/*.ts", 8 | "**/.client/**/*.tsx" 9 | ], 10 | "compilerOptions": { 11 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 12 | "types": [ 13 | "@remix-run/node", 14 | "vite/client", 15 | "./node_modules/@remix-run/react/future/single-fetch.d.ts" 16 | ], 17 | "isolatedModules": true, 18 | "esModuleInterop": true, 19 | "jsx": "react-jsx", 20 | "module": "ESNext", 21 | "moduleResolution": "Bundler", 22 | "resolveJsonModule": true, 23 | "target": "ES2022", 24 | "strict": true, 25 | "allowJs": true, 26 | "skipLibCheck": true, 27 | "forceConsistentCasingInFileNames": true, 28 | "baseUrl": ".", 29 | "paths": { 30 | "#*": ["./*"] 31 | }, 32 | 33 | // Vite takes care of building everything, not tsc. 34 | "noEmit": true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/routes/_protected.tsx: -------------------------------------------------------------------------------- 1 | import { serverOnly$ } from 'vite-env-only' 2 | import { UserContext, requireAuth } from '#app/middleware/requireAuth' 3 | 4 | export const middleware = serverOnly$([requireAuth]) 5 | 6 | import { type LoaderFunctionArgs } from '@remix-run/node' 7 | import { Form, Link, Outlet, useLoaderData } from '@remix-run/react' 8 | 9 | export async function loader({ context }: LoaderFunctionArgs) { 10 | const user = context.get(UserContext) 11 | return { user } 12 | } 13 | 14 | export default function Component() { 15 | const { user } = useLoaderData() 16 | 17 | return ( 18 |
19 |
27 |

Welcome {user.name}

28 | Home 29 |
30 | 31 |
32 |
33 | 34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /packages/remix-express-dev-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-express-dev-server", 3 | "version": "0.4.6", 4 | "description": "This package includes a Vite plugin to use in your Remix app. It imports the Express app you create in entry.server.tsx.", 5 | "type": "module", 6 | "main": "./dist/index.js", 7 | "sideEffects": false, 8 | "exports": { 9 | "./package.json": "./package.json", 10 | ".": "./dist/index.js" 11 | }, 12 | "files": [ 13 | "dist/**/*.js", 14 | "dist/**/*.d.ts", 15 | "README.md", 16 | "CHANGELOG.md" 17 | ], 18 | "scripts": { 19 | "build": "tsc --project tsconfig.build.json --module esnext --outDir ./dist" 20 | }, 21 | "author": "", 22 | "license": "ISC", 23 | "dependencies": { 24 | "minimatch": "^9.0.4" 25 | }, 26 | "devDependencies": { 27 | "@remix-run/dev": "^2.9.1", 28 | "esbuild": "^0.20.2", 29 | "prettier": "^3.2.5", 30 | "typescript": "^5.4.5", 31 | "vite": "^5.2.10" 32 | }, 33 | "peerDependencies": { 34 | "@remix-run/dev": "^2.8.1", 35 | "esbuild": "^0.20.2", 36 | "vite": "^5.2.8" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/remix-create-express-app/src/routes.ts: -------------------------------------------------------------------------------- 1 | import { ServerBuild } from '@remix-run/node' 2 | 3 | export type RouteObject = ServerBuild['routes']['root'] 4 | export type ReactRouterRouteObject = RouteObject & { 5 | children: ReactRouterRouteObject[] 6 | } 7 | 8 | let routes: ReactRouterRouteObject[] 9 | export function setRoutes(build: ServerBuild) { 10 | routes = convertRoutes(build.routes) 11 | } 12 | export function getRoutes() { 13 | return routes 14 | } 15 | 16 | // convert Remix routes to React Router routes 17 | function convertRoutes(routes: ServerBuild['routes']) { 18 | if (!routes) { 19 | return [] 20 | } 21 | 22 | const routeConfigs = Object.values(routes) 23 | 24 | function getChildren(parentId: string): ReactRouterRouteObject[] { 25 | return routeConfigs 26 | .filter(route => route.parentId === parentId) 27 | .map((route: RouteObject) => { 28 | return { 29 | ...route, 30 | children: getChildren(route.id), 31 | } 32 | }) 33 | } 34 | return [ 35 | { ...routes['root'], children: getChildren('root') }, 36 | ] as ReactRouterRouteObject[] 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # remix-express-vite-plugin 2 | 3 | ## Description 4 | 5 | This repo contains the following packages: 6 | 7 | - [`remix-express-dev-server`](./packages/remix-express-dev-server) 8 | - [`remix-create-express-app`](./packages/remix-create-express-app) 9 | 10 | These two packages work hand-in-hand to enable you to bundle your Express app 11 | with your Remix app via _entry.server.tsx_. The Vite plugin manages the development 12 | server and passes requests to your Express app. 13 | 14 | ## Remix Middleware and Server Context API 15 | 16 | This package also unlocks the ability to use _Unofficial_ Remix Middleware and 17 | Server Context API based on the RFC. 18 | 19 | See the [README](./packages/remix-create-express-app/README.md#Middleware) 20 | for details. 21 | 22 | ## Installation 23 | 24 | Install the following npm packages 25 | 26 | ```bash 27 | npm install -D remix-express-dev-server 28 | npm install remix-create-express-app 29 | ``` 30 | 31 | ## Configuration 32 | 33 | See the individual README files for more instructions. 34 | 35 | ## Example 36 | 37 | There's also an example app showing how to configure the Vite plugin and 38 | create the Express app. 39 | 40 | - [`remix-vite-express`](./example/remix-vite-express) 41 | -------------------------------------------------------------------------------- /packages/remix-express-dev-server/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v0.4.6 4 | 5 | - ✨ Add `vitePaths` option in `expressDevServer` plugin 6 | 7 | ## v0.4.5 8 | 9 | - 🐛 Move `configure` call before default middleware 10 | 11 | ## v0.4.4 12 | 13 | - 🐛 Ensure remix SSR module is loaded 14 | 15 | ## v0.4.0 16 | 17 | - 🐛 Ensure module is ready before accessing [#27] 18 | - ✨ Add support for app Promise 19 | - 🔨 Ensure plugin runs at end via `enforce: post` 20 | 21 | ## v0.2.7 22 | 23 | - 🐛 Use the moduleGraph to load the server entry point so that we don't call and transform the remix entry server twice [#25] 24 | 25 | ## v0.2.6 26 | 27 | - 🐛 Move minimatch to a dependency [#22] 28 | 29 | ## v0.2.5 30 | 31 | - ✨ Add `configureServer` option in `expressDevServer` plugin [#6] 32 | 33 | ## v0.2.4 34 | 35 | - 🐛 Check to see if physical file exists and send to Vite [#12] 36 | 37 | ## v0.2.2 38 | 39 | - 🔥 Remove console.log 40 | 41 | ## v0.2.1 42 | 43 | - 🐛 Add appDirectory config to support non-app folder [#11] 44 | 45 | ## v0.2.0 46 | 47 | - 🚨 Breaking Change: split package into two separate packages 48 | - 🔥 Remove the Remix Vite `expressPreset` 49 | 50 | ## v0.1.1 51 | 52 | - 🐛 Cannot find build/server/remix.js [#1] 53 | 54 | ## v0.1.0 55 | 56 | - 🎉 Initial commit 57 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/routes/_protected.dashboard._index.tsx: -------------------------------------------------------------------------------- 1 | import { UserContext } from '#app/middleware/requireAuth' 2 | import { 3 | ActionFunctionArgs, 4 | LoaderFunctionArgs, 5 | redirect, 6 | } from '@remix-run/node' 7 | import { Form, useLoaderData, useFetcher, Outlet } from '@remix-run/react' 8 | 9 | export async function loader({ context }: LoaderFunctionArgs) { 10 | const user = context.get(UserContext) 11 | return { user } 12 | } 13 | 14 | export async function action({ request, context }: ActionFunctionArgs) { 15 | const url = new URL(request.url) 16 | if (context.get(UserContext).name !== 'kiliman') { 17 | throw Error('Not kiliman') 18 | } 19 | return { test: 'ok', url: url.toString() } 20 | } 21 | 22 | export default function DashboardIndex() { 23 | const { user } = useLoaderData() 24 | const fetcher = useFetcher() 25 | return ( 26 |
27 | 40 |
{JSON.stringify(fetcher.data, null, 2)}
41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /packages/remix-create-express-app/src/context.ts: -------------------------------------------------------------------------------- 1 | const SERVER_CONTEXT_MAP = Symbol('SERVER_CONTEXT_MAP') 2 | 3 | export type ContextType = Record & T 4 | 5 | export type ServerContext = { 6 | get: typeof contextGet 7 | set: typeof contextSet 8 | [SERVER_CONTEXT_MAP]: ContextMap 9 | } 10 | 11 | type ContextMap = Map 12 | 13 | export function createContext(): ContextType { 14 | return {} as ContextType 15 | } 16 | 17 | function getContextMap(context: ServerContext) { 18 | let contextMap = context[SERVER_CONTEXT_MAP] 19 | if (!contextMap) { 20 | contextMap = new Map() 21 | context[SERVER_CONTEXT_MAP] = contextMap 22 | } 23 | return contextMap 24 | } 25 | 26 | export function contextSet(contextType: ContextType, value: T) { 27 | // @ts-expect-error this 28 | let context = this as ServerContext 29 | let contextMap = getContextMap(context) 30 | contextMap.set(contextType, value) 31 | } 32 | 33 | export function contextGet(contextType: ContextType) { 34 | // @ts-expect-error this 35 | let context = this as ServerContext 36 | let contextMap = getContextMap(context) 37 | 38 | const value = contextMap.get(contextType) 39 | if (value === undefined) { 40 | throw new Error(`Context not found for ${contextType}`) 41 | } 42 | return value as T 43 | } 44 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import { SessionContext } from '#app/middleware/session' 2 | import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node' 3 | import { Link, useLoaderData } from '@remix-run/react' 4 | 5 | export const meta: MetaFunction = () => { 6 | return [ 7 | { title: 'New Remix App' }, 8 | { name: 'description', content: 'Welcome to Remix!' }, 9 | ] 10 | } 11 | 12 | export async function loader({ context }: LoaderFunctionArgs) { 13 | const session = context.get(SessionContext) 14 | const error = session.get('error') 15 | return { error } 16 | } 17 | 18 | export default function Index() { 19 | const { error } = useLoaderData() 20 | return ( 21 |
22 |

Welcome to Remix

23 | {error && ( 24 |

{JSON.stringify(error, null, 2)}

25 | )} 26 |
    27 |
  • 28 | Test 29 |
  • 30 |
  • 31 | Test Redirect 32 |
  • 33 |
  • 34 | Test Error 35 |
  • 36 |
  • 37 | Dashboard 38 |
  • 39 |
40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /packages/remix-create-express-app/.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "remix-create-express-app", 3 | "projectOwner": "kiliman", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": ["README.md"], 7 | "imageSize": 100, 8 | "commit": false, 9 | "commitConvention": "none", 10 | "contributors": [ 11 | { 12 | "login": "kiliman", 13 | "name": "Michael Carter", 14 | "avatar_url": "https://avatars.githubusercontent.com/u/47168?v=4", 15 | "profile": "https://kiliman.dev/", 16 | "contributions": ["code"] 17 | }, 18 | { 19 | "login": "wKovacs64", 20 | "name": "Justin Hall", 21 | "avatar_url": "https://avatars.githubusercontent.com/u/1288694?v=4", 22 | "profile": "https://justinrhall.dev/", 23 | "contributions": ["code"] 24 | }, 25 | { 26 | "login": "lsthornt", 27 | "name": "Levi Thornton", 28 | "avatar_url": "https://avatars.githubusercontent.com/u/569689?v=4", 29 | "profile": "https://github.com/lsthornt", 30 | "contributions": ["code"] 31 | }, 32 | { 33 | "login": "thomaswelton", 34 | "name": "Thomas Welton", 35 | "avatar_url": "https://avatars.githubusercontent.com/u/678372?v=4", 36 | "profile": "https://github.com/thomaswelton", 37 | "contributions": ["code"] 38 | } 39 | ], 40 | "contributorsPerLine": 7, 41 | "linkToUsage": true 42 | } 43 | 44 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/routes/test.tsx: -------------------------------------------------------------------------------- 1 | import { SessionContext } from '#app/middleware/session' 2 | import { 3 | type LoaderFunctionArgs, 4 | type ActionFunctionArgs, 5 | redirect, 6 | } from '@remix-run/node' 7 | import { Form, useLoaderData } from '@remix-run/react' 8 | 9 | export async function loader({ context }: LoaderFunctionArgs) { 10 | // get the session from context 11 | const session = context.get(SessionContext) 12 | const count = Number(session.get('count') || 0) 13 | return { message: context.sayHello(), now: Date.now(), count } 14 | } 15 | 16 | export async function action({ request, context }: ActionFunctionArgs) { 17 | const session = context.get(SessionContext) 18 | const formData = await request.formData() 19 | if (formData.has('inc')) { 20 | // you should only see set-cookie header when session is modified 21 | const count = Number(session.get('count') || 0) 22 | session.set('count', count + 1) 23 | } else if (formData.has('flash')) { 24 | session.flash('error', 'This is a flash message') 25 | throw redirect('/') 26 | } 27 | throw redirect('/test') 28 | } 29 | 30 | export default function Component() { 31 | const loaderData = useLoaderData() 32 | const { count } = loaderData 33 | 34 | return ( 35 |
36 |

Test

37 |

Message from context

38 |
39 | 40 | 41 | 42 |
43 |
{JSON.stringify(loaderData, null, 2)}
44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /example/remix-vite-express/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-vite-express", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "imports": { 7 | "#*": "./*" 8 | }, 9 | "scripts": { 10 | "build": "remix vite:build", 11 | "dev": "vite", 12 | "start": "cross-env NODE_ENV=production node ./build/server/index.js", 13 | "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", 14 | "typecheck": "tsc" 15 | }, 16 | "dependencies": { 17 | "@remix-run/node": "^2.9.1", 18 | "@remix-run/react": "^2.9.1", 19 | "@remix-run/serve": "^2.9.1", 20 | "cross-env": "^7.0.3", 21 | "isbot": "^4.1.0", 22 | "morgan": "^1.10.0", 23 | "react": "^19.0.0-beta-94eed63c49-20240425", 24 | "react-dom": "^19.0.0-beta-94eed63c49-20240425", 25 | "remix-create-express-app": "*" 26 | }, 27 | "devDependencies": { 28 | "@remix-run/dev": "^2.9.1", 29 | "@types/morgan": "^1.9.9", 30 | "@types/react": "^18.2.20", 31 | "@types/react-dom": "^18.2.7", 32 | "@typescript-eslint/eslint-plugin": "^6.7.4", 33 | "@typescript-eslint/parser": "^6.7.4", 34 | "eslint": "^8.38.0", 35 | "eslint-import-resolver-typescript": "^3.6.1", 36 | "eslint-plugin-import": "^2.28.1", 37 | "eslint-plugin-jsx-a11y": "^6.7.1", 38 | "eslint-plugin-react": "^7.33.2", 39 | "eslint-plugin-react-hooks": "^4.6.0", 40 | "remix-express-dev-server": "*", 41 | "typescript": "^5.1.6", 42 | "vite": "^5.1.0", 43 | "vite-env-only": "^2.2.1", 44 | "vite-tsconfig-paths": "^4.2.1" 45 | }, 46 | "overrides": { 47 | "react": "$react", 48 | "react-dom": "$react-dom" 49 | }, 50 | "engines": { 51 | "node": ">=18.0.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/remix-create-express-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-create-express-app", 3 | "version": "0.4.5", 4 | "description": "This package includes helper function to create an Express app in your entry.server.tsx file. It allows you to customize your server. It also adds support for Middleware to Remix", 5 | "type": "module", 6 | "main": "./dist/index.js", 7 | "sideEffects": false, 8 | "exports": { 9 | ".": "./dist/index.js", 10 | "./context": "./dist/context.js", 11 | "./middleware": "./dist/middleware.js", 12 | "./remix": "./dist/remix.js", 13 | "./routes": "./dist/routes.js", 14 | "./package.json": "./package.json" 15 | }, 16 | "files": [ 17 | "dist/**/*.js", 18 | "dist/**/*.d.ts", 19 | "README.md", 20 | "CHANGELOG.md" 21 | ], 22 | "scripts": { 23 | "clean": "rimraf dist", 24 | "build": "npm run clean && esbuild src/* --format=esm --platform=node --outdir=dist/ && npm run build:types", 25 | "build:types": "tsc --project tsconfig.build.json --module esnext --outDir ./dist" 26 | }, 27 | "author": "", 28 | "license": "ISC", 29 | "dependencies": { 30 | "@remix-run/express": "^2.9.1", 31 | "@remix-run/node": "^2.9.1", 32 | "@remix-run/react": "^2.9.1", 33 | "compression": "^1.7.4", 34 | "express": "^4.19.2", 35 | "morgan": "^1.10.0", 36 | "source-map-support": "^0.5.21" 37 | }, 38 | "devDependencies": { 39 | "@types/compression": "^1.7.5", 40 | "@types/express": "^4.17.21", 41 | "@types/morgan": "^1.9.9", 42 | "@types/source-map-support": "^0.5.10", 43 | "all-contributors-cli": "^6.26.1", 44 | "esbuild": "^0.20.2", 45 | "prettier": "^3.2.5", 46 | "rimraf": "^5.0.5", 47 | "typescript": "^5.4.5", 48 | "vite": "^5.2.10" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/middleware/session.ts: -------------------------------------------------------------------------------- 1 | // middleware/session.ts 2 | import { type SessionStorage, type Session } from '@remix-run/node' 3 | import { createContext } from 'remix-create-express-app/context' 4 | import { MiddlewareFunctionArgs } from 'remix-create-express-app/middleware' 5 | 6 | export type SessionMiddlewareArgs = { 7 | isCookieSessionStorage: boolean 8 | } 9 | 10 | // create SessionContext for use with context.get and .set 11 | export const SessionContext = 12 | createContext>() 13 | 14 | // creates session middleware that auto-commits the session cookie when mutated 15 | export function createSessionMiddleware(storage: SessionStorage) { 16 | const { getSession, commitSession } = storage 17 | 18 | return async ({ request, context, next }: MiddlewareFunctionArgs) => { 19 | const session = await getSession(request.headers.get('Cookie')) 20 | type PropType = keyof typeof session 21 | 22 | // setup a proxy to track if the session has been modified 23 | // so we can commit it back to the store 24 | const sessionProxy = { 25 | _isDirty: false, 26 | _data: JSON.stringify(session.data), 27 | get isDirty() { 28 | return this._isDirty || this._data !== JSON.stringify(session.data) 29 | }, 30 | get(target: typeof session, prop: PropType) { 31 | this._isDirty ||= ['set', 'unset', 'destroy', 'flash'].includes(prop) 32 | return target[prop] 33 | }, 34 | } 35 | 36 | const session$ = new Proxy(session, sessionProxy) as typeof session 37 | // set the session context 38 | context.set(SessionContext, session$) 39 | 40 | const response = await next() 41 | 42 | if (sessionProxy.isDirty) { 43 | const result = await commitSession(session$) 44 | response.headers.append('Set-Cookie', result) 45 | } 46 | return response 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ErrorResponse, 3 | Links, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | isRouteErrorResponse, 9 | useRouteError, 10 | } from '@remix-run/react' 11 | 12 | import { createSessionMiddleware } from '#app/middleware/session' 13 | import { serverOnly$ } from 'vite-env-only' 14 | import { createCookieSessionStorage } from '@remix-run/node' 15 | 16 | const session = createSessionMiddleware( 17 | createCookieSessionStorage({ 18 | cookie: { 19 | name: '__session', 20 | path: '/', 21 | sameSite: 'lax', 22 | secrets: ['s3cret1'], 23 | }, 24 | }), 25 | ) 26 | 27 | // export your middleware as array of functions that Remix will call 28 | // wrap middleware in serverOnly$ to prevent it from being bundled in the browser 29 | // since remix doesn't know about middleware yet 30 | export const middleware = serverOnly$([session]) 31 | 32 | export function Layout({ children }: { children: React.ReactNode }) { 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | {children} 43 | 44 | 45 | 46 | 47 | ) 48 | } 49 | 50 | export default function App() { 51 | return 52 | } 53 | 54 | export function ErrorBoundary() { 55 | const routeError = useRouteError() 56 | 57 | if (isRouteErrorResponse(routeError)) { 58 | const response = routeError as ErrorResponse 59 | return ( 60 | <> 61 |

{response.status}

62 |

{response.statusText}

63 | 64 | ) 65 | } 66 | const error = routeError as Error 67 | return ( 68 | <> 69 |

ERROR!

70 |

{error.message}

71 |
{error.stack}
72 | 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /example/remix-vite-express/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This is intended to be a basic starting point for linting in your app. 3 | * It relies on recommended configs out of the box for simplicity, but you can 4 | * and should modify this configuration to best suit your team's needs. 5 | */ 6 | 7 | /** @type {import('eslint').Linter.Config} */ 8 | module.exports = { 9 | root: true, 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | env: { 18 | browser: true, 19 | commonjs: true, 20 | es6: true, 21 | }, 22 | ignorePatterns: ["!**/.server", "!**/.client"], 23 | 24 | // Base config 25 | extends: ["eslint:recommended"], 26 | 27 | overrides: [ 28 | // React 29 | { 30 | files: ["**/*.{js,jsx,ts,tsx}"], 31 | plugins: ["react", "jsx-a11y"], 32 | extends: [ 33 | "plugin:react/recommended", 34 | "plugin:react/jsx-runtime", 35 | "plugin:react-hooks/recommended", 36 | "plugin:jsx-a11y/recommended", 37 | ], 38 | settings: { 39 | react: { 40 | version: "detect", 41 | }, 42 | formComponents: ["Form"], 43 | linkComponents: [ 44 | { name: "Link", linkAttribute: "to" }, 45 | { name: "NavLink", linkAttribute: "to" }, 46 | ], 47 | "import/resolver": { 48 | typescript: {}, 49 | }, 50 | }, 51 | }, 52 | 53 | // Typescript 54 | { 55 | files: ["**/*.{ts,tsx}"], 56 | plugins: ["@typescript-eslint", "import"], 57 | parser: "@typescript-eslint/parser", 58 | settings: { 59 | "import/internal-regex": "^~/", 60 | "import/resolver": { 61 | node: { 62 | extensions: [".ts", ".tsx"], 63 | }, 64 | typescript: { 65 | alwaysTryTypes: true, 66 | }, 67 | }, 68 | }, 69 | extends: [ 70 | "plugin:@typescript-eslint/recommended", 71 | "plugin:import/recommended", 72 | "plugin:import/typescript", 73 | ], 74 | }, 75 | 76 | // Node 77 | { 78 | files: [".eslintrc.cjs"], 79 | env: { 80 | node: true, 81 | }, 82 | }, 83 | ], 84 | }; 85 | -------------------------------------------------------------------------------- /packages/remix-create-express-app/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v0.4.5 4 | 5 | - 🐛 Move `configure` call before default middleware 6 | 7 | ## v0.4.4 8 | 9 | - 🔥 Remove context hack 10 | 11 | ## v0.4.3 12 | 13 | - 🐛 Dynamically import setRemixDevLoadContext in development only 14 | 15 | ## v0.4.2 16 | 17 | - 🐛 Fix import reference 18 | 19 | ## v0.4.1 20 | 21 | - 🐛 Ensure Vite plugin uses specified getLoadContext for SSR+HMR 22 | 23 | ## v0.4.0 24 | 25 | - ✨ `createExpressApp` now supports `async` for configuration 26 | 27 | ## v0.3.12 28 | 29 | - ↩️ Revert "Prevent duplicate calls to createExpressApp but still support HMR [#24]" 30 | 31 | ## v0.3.11 32 | 33 | - 🐛 Modify request.url with originalUrl [#23] 34 | - 🐛 Prevent duplicate calls to createExpressApp but still support HMR [#24] 35 | - 🐛 Use `x-forwarded-host` for resolved hostname 36 | 37 | ## v0.3.10 38 | 39 | - ~~🐛 Move minimatch to a dependency [#22]~~ 40 | 41 | ## v0.3.9 42 | 43 | - 🐛 Move minimatch to a dependency [#22] 44 | 45 | ## v0.3.8 46 | 47 | - ✨ Add buildDirectory and serverBuildFile as options to CreateExpressAppArgs [#21] 48 | - 🐛 Support for partial data requests 49 | - 🐛 Run all matching middleware even for just root data requests [#19] 50 | - 🐛 Modify originalUrl to strip data url for middleware requests 51 | 52 | ## v0.3.7 53 | 54 | - 🐛 Handle data requests with search params [#14] 55 | 56 | ## v0.3.6 57 | 58 | - ✨ Add new ServerContext API 59 | 60 | ## v0.3.5 61 | 62 | - ✨ Add redirect support from middleware 63 | 64 | ## v0.3.4 65 | 66 | - 🔨 Make createExpressApp args optional 67 | 68 | ## v0.3.3 69 | 70 | - 🔨 Middleware function must return a response or throw 71 | - 🐛 Add `params` object to middleware function call [#7] 72 | - 🐛 Ignore headers that start with `:` from http2 [#10] 73 | 74 | ## v0.3.2 75 | 76 | - 🐛 Fix import of production build on Windows [#9] 77 | - ✨ Create default app similar to Remix App Server if no configure provided 78 | - ✨ Pass the Remix ServerBuild to the getLoadContext function 79 | 80 | ## v0.3.1 81 | 82 | - 🐛 Fix package build: "Cannot find module" ESM is hard! [#4] 83 | 84 | ## v0.3.0 85 | 86 | - ✨ Add Remix Middleware support 87 | 88 | ## v0.2.0 89 | 90 | - 🚨 Breaking Change: split package into two separate packages 91 | - ✨ Add support for custom servers and express module 92 | - ✨ Add support for custom request handlers 93 | 94 | ## v0.1.1 95 | 96 | - 🐛 Cannot find build/server/remix.js [#1] 97 | 98 | ## v0.1.0 99 | 100 | - 🎉 Initial commit 101 | -------------------------------------------------------------------------------- /example/remix-vite-express/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from 'node:stream' 8 | 9 | import { type AppLoadContext, type EntryContext } from '@remix-run/node' 10 | import { createReadableStreamFromReadable } from '@remix-run/node' 11 | import { RemixServer } from '@remix-run/react' 12 | import { isbot } from 'isbot' 13 | import { renderToPipeableStream } from 'react-dom/server' 14 | import { createExpressApp } from 'remix-create-express-app' 15 | import morgan from 'morgan' 16 | import { sayHello } from '#app/hello.server' 17 | 18 | const ABORT_DELAY = 5_000 19 | 20 | export default function handleRequest( 21 | request: Request, 22 | responseStatusCode: number, 23 | responseHeaders: Headers, 24 | remixContext: EntryContext, 25 | // This is ignored so we can keep it in the template for visibility. Feel 26 | // free to delete this parameter in your app if you're not using it! 27 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 28 | loadContext: AppLoadContext, 29 | ) { 30 | const handlerName = isbot(request.headers.get('user-agent') || '') 31 | ? 'onAllReady' 32 | : 'onShellReady' 33 | return new Promise((resolve, reject) => { 34 | let shellRendered = false 35 | const { pipe, abort } = renderToPipeableStream( 36 | , 41 | { 42 | [handlerName]() { 43 | shellRendered = true 44 | const body = new PassThrough() 45 | const stream = createReadableStreamFromReadable(body) 46 | 47 | responseHeaders.set('Content-Type', 'text/html') 48 | 49 | resolve( 50 | new Response(stream, { 51 | headers: responseHeaders, 52 | status: responseStatusCode, 53 | }), 54 | ) 55 | 56 | pipe(body) 57 | }, 58 | onShellError(error: unknown) { 59 | reject(error) 60 | }, 61 | onError(error: unknown) { 62 | responseStatusCode = 500 63 | // Log streaming rendering errors from inside the shell. Don't log 64 | // errors encountered during initial shell rendering since they'll 65 | // reject and get logged in handleDocumentRequest. 66 | if (shellRendered) { 67 | console.error(error) 68 | } 69 | }, 70 | }, 71 | ) 72 | 73 | setTimeout(abort, ABORT_DELAY) 74 | }) 75 | } 76 | 77 | export const app = createExpressApp({ 78 | configure: app => { 79 | // customize your express app with additional middleware 80 | app.use(morgan('tiny')) 81 | }, 82 | getLoadContext: () => { 83 | // return the AppLoadContext 84 | return { sayHello } as AppLoadContext 85 | }, 86 | unstable_middleware: true, 87 | }) 88 | -------------------------------------------------------------------------------- /packages/remix-express-dev-server/README.md: -------------------------------------------------------------------------------- 1 | # remix-express-dev-server 2 | 3 | This package is a Vite plugin that loads your Express app in development. It 4 | expects the app to have been created in your _entry.server.tsx_ file via the 5 | `createExpressApp` help. 6 | 7 | ## Installation 8 | 9 | Install the following npm package: 10 | 11 | ```bash 12 | npm install -D remix-express-dev-server 13 | ``` 14 | 15 | ## Configuration 16 | 17 | Add the Vite plugin to your _vite.config.ts_ file. Also, configure 18 | `build.target='esnext'` to support _top-level await_. 19 | 20 | ```ts 21 | import { expressDevServer } from 'remix-express-dev-server' 22 | import { vitePlugin as remix } from '@remix-run/dev' 23 | import { defineConfig } from 'vite' 24 | import tsconfigPaths from 'vite-tsconfig-paths' 25 | 26 | export default defineConfig({ 27 | build: { 28 | target: 'esnext', 29 | }, 30 | plugins: [ 31 | expressDevServer(), // add expressDevServer plugin 32 | remix(), 33 | tsconfigPaths(), 34 | ], 35 | }) 36 | ``` 37 | 38 | ### Options 39 | 40 | The `expressDevServer` plugin also accepts options: 41 | 42 | ```ts 43 | export type DevServerOptions = { 44 | entry?: string // Express app entry: default = 'virtual:remix/server-build' 45 | entryName?: string // name of express app export: default = app 46 | appDirectory?: string // path to remix app directory: default = ./app 47 | vitePaths?: RegExp[] // array of path patterns to send to vite server: defeault = [] 48 | configureServer?: (server: http.Server) => void // allow additional configuration 49 | // of Vite dev server (like setting up socket.io) 50 | } 51 | ``` 52 | 53 | ## Package.json 54 | 55 | The `scripts` for `dev` and `build` have been simplified. 56 | 57 | Update `scripts` in your _package.json_ file. For development, Vite will handle 58 | starting express and calling Remix to build your app, so simply call `vite`. 59 | 60 | For building, Remix will build your app and create the server and client bundles. 61 | The server bundle is in `./build/server/index.js` 62 | 63 | To run your production build, call `node ./build/server/index.js` 64 | 65 | Make sure to set `NODE_ENV=production`, otherwise it will not create your server. 66 | 67 | ```json 68 | { 69 | "scripts": { 70 | "dev": "vite", 71 | "build": "remix vite:build", 72 | "start": "NODE_ENV=production node ./build/server/index.js" 73 | } 74 | } 75 | ``` 76 | 77 | ## Advanced configuration 78 | 79 | ### Socket.io 80 | 81 | You can wrap the Vite dev server with the socket.io server. 82 | 83 | > NOTE: `configureServer` is only called for development. In production, use the 84 | > `createExpressApp({ createServer })` function to initialize socket.io. You 85 | > can import same init function in both places. 86 | 87 | ```ts 88 | // vite.config.ts 89 | export default defineConfig({ 90 | plugins: [ 91 | expressDevServer({ 92 | configureServer(server) { 93 | const io = new Server(server) 94 | 95 | io.on('upgrade', async (req, socket, head) => { 96 | console.log( 97 | `Attemping to upgrade connection at url ${ 98 | req.url 99 | } with headers: ${JSON.stringify(req.headers)}`, 100 | ) 101 | 102 | socket.on('error', err => { 103 | console.error('Connection upgrade error:', err) 104 | }) 105 | 106 | console.log(`Client connected, upgrading their connection...`) 107 | }) 108 | io.on('connection', socket => { 109 | console.log('Client connected') 110 | socket.emit('server', 'hello from server') 111 | socket.on('client', data => { 112 | console.log('Client sent:', data) 113 | socket.emit('server', data) 114 | }) 115 | socket.on('disconnect', () => { 116 | console.log('Client disconnected') 117 | }) 118 | }) 119 | }, 120 | }), 121 | // ... 122 | ], 123 | }) 124 | ``` 125 | -------------------------------------------------------------------------------- /packages/remix-express-dev-server/src/index.ts: -------------------------------------------------------------------------------- 1 | import type http from 'node:http' 2 | import { minimatch } from 'minimatch' 3 | import type { Connect, Plugin as VitePlugin, ViteDevServer } from 'vite' 4 | import fs from 'node:fs' 5 | 6 | export type DevServerOptions = { 7 | entry?: string 8 | exportName?: string 9 | appDirectory?: string 10 | vitePaths?: RegExp[] 11 | configureServer?: (server: http.Server) => void 12 | } 13 | 14 | export const defaultOptions: Required = { 15 | entry: 'virtual:remix/server-build', 16 | exportName: 'app', 17 | appDirectory: './app', 18 | vitePaths: [], 19 | configureServer: () => {}, 20 | } 21 | 22 | export type Fetch = (request: Request) => Promise 23 | export type AppHandle = { 24 | handle: ( 25 | req: http.IncomingMessage, 26 | res: http.ServerResponse, 27 | next: Connect.NextFunction, 28 | ) => void 29 | } 30 | 31 | export function expressDevServer(options?: DevServerOptions): VitePlugin { 32 | const entry = options?.entry ?? defaultOptions.entry 33 | const exportName = options?.exportName ?? defaultOptions.exportName 34 | const configureServer = 35 | options?.configureServer ?? defaultOptions.configureServer 36 | let appDirectory = normalizeAppDirectory( 37 | options?.appDirectory ?? defaultOptions.appDirectory, 38 | ) 39 | 40 | const appDirectoryPattern = new RegExp(`^${escapeRegExp(appDirectory)}`) 41 | 42 | const plugin: VitePlugin = { 43 | name: 'remix-express-dev-server', 44 | enforce: 'post', 45 | configureServer: async server => { 46 | async function createMiddleware( 47 | server: ViteDevServer, 48 | ): Promise { 49 | // allow for additional configuration of vite dev server 50 | configureServer(server.httpServer as http.Server) 51 | 52 | return async function ExpressDevServerMiddleware( 53 | req: http.IncomingMessage, 54 | res: http.ServerResponse, 55 | next: Connect.NextFunction, 56 | ): Promise { 57 | // exclude requests that should be handled by Vite dev server 58 | const exclude = [/^\/@.+$/, /^\/node_modules\/.*/, ...(options?.vitePaths ?? [])] 59 | 60 | for (const pattern of exclude) { 61 | if (req.url) { 62 | if (pattern instanceof RegExp) { 63 | if (pattern.test(req.url)) { 64 | return next() 65 | } 66 | } else if (minimatch(req.url?.toString(), pattern)) { 67 | return next() 68 | } 69 | } 70 | } 71 | // check if url is a physical file in the app directory 72 | if (appDirectoryPattern.test(req.url!)) { 73 | const url = new URL(req.url!, 'http://localhost') 74 | if (fs.existsSync(url.pathname.slice(1))) { 75 | return next() 76 | } 77 | } 78 | 79 | let ssrModule 80 | 81 | try { 82 | let module = await server.moduleGraph.getModuleByUrl(entry) 83 | if (module) { 84 | ssrModule = module.ssrModule 85 | } 86 | } catch (e) { 87 | return next(e) 88 | } 89 | if (!ssrModule) { 90 | ssrModule = await server.ssrLoadModule(entry) 91 | } 92 | 93 | const entryModule = ssrModule?.entry?.module 94 | 95 | if (entryModule === undefined) { 96 | return next() 97 | } 98 | 99 | // explicitly typed since express handle function is not exported 100 | let app = entryModule[exportName] as AppHandle | Promise 101 | if (!app) { 102 | return next( 103 | new Error( 104 | `Failed to find a named export "${exportName}" from ${entry}`, 105 | ), 106 | ) 107 | } 108 | if (app instanceof Promise) { 109 | app = await app 110 | } 111 | // pass request to the Express app 112 | app.handle(req, res, next) 113 | } 114 | } 115 | 116 | server.middlewares.use(await createMiddleware(server)) 117 | server.httpServer?.on('close', async () => {}) 118 | }, 119 | } 120 | return plugin 121 | } 122 | 123 | function normalizeAppDirectory(appDirectory: string) { 124 | // replace backslashes with forward slashes 125 | appDirectory = appDirectory.replace(/\\/g, '/') 126 | // remove leading dot 127 | if (appDirectory.startsWith('.')) appDirectory = appDirectory.slice(1) 128 | // add leading slash 129 | if (!appDirectory.startsWith('/')) appDirectory = `/${appDirectory}` 130 | // add trailing slash 131 | if (!appDirectory.endsWith('/')) appDirectory = `${appDirectory}/` 132 | return appDirectory 133 | } 134 | 135 | function escapeRegExp(string: string) { 136 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 137 | } 138 | -------------------------------------------------------------------------------- /packages/remix-create-express-app/tests/middleware.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | 3 | async function test( 4 | middleware: any[], 5 | handleRemixRequest: (...args) => Promise, 6 | ) { 7 | let request = new Request('http://localhost:3000/') 8 | let context = {} 9 | let params = {} 10 | let matches = [] 11 | let index = 0 12 | 13 | let lastCaughtError 14 | let lastCaughtResponse 15 | 16 | // eslint-disable-next-line no-inner-declarations 17 | // @ts-ignore-next-line 18 | async function next() { 19 | try { 20 | const fn = middleware[index++] 21 | if (!fn) { 22 | return await handleRemixRequest(request, context) 23 | } 24 | return fn({ 25 | request, 26 | params, 27 | context, 28 | matches, 29 | // @ts-ignore-next-line 30 | next, 31 | }) 32 | } catch (e) { 33 | // stop middleware 34 | index = middleware.length 35 | if (e instanceof Response) { 36 | console.log(`middleware response`, e.status, e.statusText) 37 | lastCaughtResponse = e 38 | return e 39 | } 40 | console.log(`middleware error: ${index}`, e.message) 41 | lastCaughtError = e 42 | } 43 | } 44 | 45 | let response 46 | try { 47 | // start middleware/remix chain 48 | response = await next() 49 | } catch (e) { 50 | if (e instanceof Response) { 51 | console.log(`initial response`, e.status, e.statusText) 52 | lastCaughtResponse = e 53 | return e 54 | } 55 | console.log(`initial error`, e.message) 56 | lastCaughtError = e 57 | } 58 | 59 | if (lastCaughtResponse) { 60 | response = lastCaughtResponse 61 | } 62 | if (lastCaughtError) { 63 | console.error('last caught error:', lastCaughtError.message) 64 | response = Response.json(lastCaughtError, { status: 500 }) 65 | } 66 | 67 | if (!response) { 68 | throw new Error('Middleware must return the Response from next()') 69 | } 70 | 71 | console.log('final response:', response.status, response.statusText) 72 | } 73 | 74 | type MiddlewareFunctionArgs = { 75 | next: () => Promise 76 | } 77 | 78 | function createMiddleware({ name, mode }: { name: string; mode: string }) { 79 | return async ({ next }: MiddlewareFunctionArgs) => { 80 | console.log(`middleware enter: ${name}`) 81 | if (mode === 'error-first') { 82 | console.log(`throwing error from middleware start ${name}`) 83 | throw new Error(`from middleware start: ${name}`) 84 | } 85 | if (mode === 'redirect-first') { 86 | console.log(`throwing redirect from middleware start: ${name}`) 87 | throw Response.redirect('http://localhost/login') 88 | } 89 | const response = await next() 90 | if (mode === 'happy') { 91 | console.log(`returning response from middleware ${name}`) 92 | return response 93 | } else if (mode === 'error') { 94 | console.log(`throwing error from middleware ${name}`) 95 | throw new Error(`from middleware: ${name}`) 96 | } else if (mode === 'redirect') { 97 | console.log(`throwing redirect from middleware: ${name}`) 98 | throw Response.redirect('http://localhost/login') 99 | } 100 | console.log(`middleware exit: ${name}`) 101 | return response 102 | } 103 | } 104 | 105 | function createRemixHandler(mode: string) { 106 | return async (request, context) => { 107 | console.log(`handle remix request: ${mode}`) 108 | if (mode === 'happy') { 109 | console.log('returning JSON response from remix') 110 | return Response.json({ message: 'from remix' }, { status: 200 }) 111 | } else if (mode === 'error') { 112 | console.log('throwing error from remix') 113 | throw new Error('from remix') 114 | } else if (mode === 'redirect') { 115 | console.log('throwing redirect from remix') 116 | throw Response.redirect('/') 117 | } 118 | } 119 | } 120 | 121 | const happyPathMiddleware = [ 122 | createMiddleware({ name: 'mw1', mode: 'happy' }), 123 | createMiddleware({ name: 'mw2', mode: 'happy' }), 124 | createMiddleware({ name: 'mw3', mode: 'happy' }), 125 | ] 126 | 127 | const happyRemixHandler = createRemixHandler('happy') 128 | 129 | console.log('😃 test happy path') 130 | await test(happyPathMiddleware, happyRemixHandler) 131 | console.log('done') 132 | 133 | const errorRemixHandler = createRemixHandler('error') 134 | 135 | console.log('😢 test error in remix') 136 | await test(happyPathMiddleware, errorRemixHandler) 137 | console.log('done') 138 | 139 | const errorInMiddleware = [ 140 | createMiddleware({ name: 'mw1', mode: 'happy' }), 141 | createMiddleware({ name: 'mw2', mode: 'happy' }), 142 | createMiddleware({ name: 'mw3', mode: 'error' }), 143 | ] 144 | 145 | console.log('😢 test error in middleware') 146 | await test(errorInMiddleware, happyRemixHandler) 147 | console.log('done') 148 | 149 | const errorInMiddlewareStart = [ 150 | createMiddleware({ name: 'mw1', mode: 'happy' }), 151 | createMiddleware({ name: 'mw2', mode: 'error-first' }), 152 | createMiddleware({ name: 'mw3', mode: 'happy' }), 153 | ] 154 | 155 | console.log('😢 test error in middleware start') 156 | await test(errorInMiddlewareStart, happyRemixHandler) 157 | console.log('done') 158 | 159 | const redirectMiddleware = [ 160 | createMiddleware({ name: 'mw1', mode: 'happy' }), 161 | createMiddleware({ name: 'mw2', mode: 'redirect-first' }), 162 | createMiddleware({ name: 'mw3', mode: 'happy' }), 163 | ] 164 | 165 | console.log('↩️ redirect middleware') 166 | await test(redirectMiddleware, happyRemixHandler) 167 | console.log('done') 168 | -------------------------------------------------------------------------------- /packages/remix-create-express-app/src/remix.ts: -------------------------------------------------------------------------------- 1 | // IDK why this is needed when it's in the tsconfig.......... 2 | // YAY PROJECT REFERENCES! 3 | /// 4 | 5 | // Copied from https://github.com/remix-run/remix/blob/main/packages/remix-express/server.ts 6 | 7 | import type * as express from 'express' 8 | import type { AppLoadContext, ServerBuild } from '@remix-run/node' 9 | import { 10 | createRequestHandler as createRemixRequestHandler, 11 | createReadableStreamFromReadable, 12 | writeReadableStreamToWritable, 13 | } from '@remix-run/node' 14 | 15 | /** 16 | * A function that returns the value to use as `context` in route `loader` and 17 | * `action` functions. 18 | * 19 | * You can think of this as an escape hatch that allows you to pass 20 | * environment/platform-specific values through to your loader/action, such as 21 | * values that are generated by Express middleware like `req.session`. 22 | */ 23 | export type GetLoadContextFunction = ( 24 | req: express.Request, 25 | res: express.Response, 26 | ) => Promise | AppLoadContext 27 | 28 | export type RequestHandler = ( 29 | req: express.Request, 30 | res: express.Response, 31 | next: express.NextFunction, 32 | ) => Promise 33 | 34 | /** 35 | * Returns a request handler for Express that serves the response using Remix. 36 | */ 37 | export function createRequestHandler({ 38 | build, 39 | getLoadContext, 40 | mode = process.env.NODE_ENV, 41 | }: { 42 | build: ServerBuild | (() => Promise) 43 | getLoadContext?: GetLoadContextFunction 44 | mode?: string 45 | }): RequestHandler { 46 | let handleRequest = createRemixRequestHandler(build, mode) 47 | 48 | return async ( 49 | req: express.Request, 50 | res: express.Response, 51 | next: express.NextFunction, 52 | ) => { 53 | try { 54 | let request = createRemixRequest(req, res) 55 | let loadContext = await getLoadContext?.(req, res) 56 | 57 | let response = await handleRequest(request, loadContext) 58 | 59 | await sendRemixResponse(res, response) 60 | } catch (error: unknown) { 61 | // Express doesn't support async functions, so we have to pass along the 62 | // error manually using next(). 63 | next(error) 64 | } 65 | } 66 | } 67 | 68 | export function createRemixHeaders( 69 | requestHeaders: express.Request['headers'], 70 | ): Headers { 71 | let headers = new Headers() 72 | 73 | for (let [key, values] of Object.entries(requestHeaders)) { 74 | // ignore headers that start with ':' 75 | if (key.startsWith(':')) continue 76 | if (values) { 77 | if (Array.isArray(values)) { 78 | for (let value of values) { 79 | headers.append(key, value) 80 | } 81 | } else { 82 | headers.set(key, values) 83 | } 84 | } 85 | } 86 | 87 | return headers 88 | } 89 | 90 | export function createRemixRequest( 91 | req: express.Request, 92 | res: express.Response, 93 | ): Request { 94 | // req.hostname doesn't include port information so grab that from 95 | // `X-Forwarded-Host` or `Host` 96 | let [hostname, hostnamePort] = 97 | (req.get('X-Forwarded-Host') ?? req.get('Host') ?? '').split(':') ?? [] 98 | let [, hostPort] = req.get('host')?.split(':') ?? [] 99 | let port = hostnamePort || hostPort 100 | let resolvedHost = `${hostname ?? req.hostname}${port ? `:${port}` : ''}` 101 | // Use `req.originalUrl` so Remix is aware of the full path 102 | let url = new URL(`${req.protocol}://${resolvedHost}${req.originalUrl}`) 103 | 104 | // Abort action/loaders once we can no longer write a response 105 | let controller = new AbortController() 106 | res.on('close', () => controller.abort()) 107 | 108 | let init: RequestInit = { 109 | method: req.method, 110 | headers: createRemixHeaders(req.headers), 111 | signal: controller.signal, 112 | } 113 | 114 | if (req.method !== 'GET' && req.method !== 'HEAD') { 115 | init.body = createReadableStreamFromReadable(req) 116 | ;(init as { duplex: 'half' }).duplex = 'half' 117 | } 118 | 119 | return new Request(url.href, init) 120 | } 121 | 122 | export function createMiddlewareRequest( 123 | req: express.Request, 124 | res: express.Response, 125 | ): Request { 126 | // req.hostname doesn't include port information so grab that from 127 | // `X-Forwarded-Host` or `Host` 128 | let [, hostnamePort] = req.get('X-Forwarded-Host')?.split(':') ?? [] 129 | let [, hostPort] = req.get('host')?.split(':') ?? [] 130 | let port = hostnamePort || hostPort 131 | // Use req.hostname here as it respects the "trust proxy" setting 132 | let resolvedHost = `${req.hostname}${port ? `:${port}` : ''}` 133 | // Use `req.originalUrl` so Remix is aware of the full path 134 | let originalUrl = req.originalUrl 135 | if (originalUrl.endsWith('.data')) { 136 | originalUrl = originalUrl.replace(/\.data$/, '') 137 | } 138 | 139 | let url = new URL(`${req.protocol}://${resolvedHost}${originalUrl}`) 140 | 141 | // Abort action/loaders once we can no longer write a response 142 | let controller = new AbortController() 143 | res.on('close', () => controller.abort()) 144 | 145 | let init: RequestInit = { 146 | method: req.method, 147 | headers: createRemixHeaders(req.headers), 148 | signal: controller.signal, 149 | } 150 | 151 | if (req.method !== 'GET' && req.method !== 'HEAD') { 152 | init.body = createReadableStreamFromReadable(req) 153 | ;(init as { duplex: 'half' }).duplex = 'half' 154 | } 155 | 156 | return new Request(url.href, init) 157 | } 158 | 159 | export async function sendRemixResponse( 160 | res: express.Response, 161 | nodeResponse: Response, 162 | ): Promise { 163 | res.statusMessage = nodeResponse.statusText 164 | res.status(nodeResponse.status) 165 | 166 | for (let [key, value] of nodeResponse.headers.entries()) { 167 | res.append(key, value) 168 | } 169 | 170 | if (nodeResponse.headers.get('Content-Type')?.match(/text\/event-stream/i)) { 171 | res.flushHeaders() 172 | } 173 | 174 | if (nodeResponse.body) { 175 | await writeReadableStreamToWritable(nodeResponse.body, res) 176 | } else { 177 | res.end() 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /packages/remix-create-express-app/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createRemixRequest, 3 | sendRemixResponse, 4 | type GetLoadContextFunction, 5 | createMiddlewareRequest, 6 | } from './remix.js' 7 | import { 8 | createRequestHandler as createRemixRequestHandler, 9 | type AppLoadContext, 10 | type ServerBuild, 11 | } from '@remix-run/node' 12 | import { matchRoutes } from '@remix-run/react' 13 | import type express from 'express' 14 | import { getRoutes } from './routes.js' 15 | import { ServerContext, contextGet, contextSet } from './context.js' 16 | 17 | export type MiddlewareFunctionArgs = { 18 | request: Request 19 | params: Record 20 | context: AppLoadContext & ServerContext 21 | matches: ReturnType 22 | next: () => Promise 23 | } 24 | 25 | export type MiddleWareFunction = ( 26 | args: MiddlewareFunctionArgs, 27 | ) => Response | Promise 28 | 29 | export type Middleware = MiddleWareFunction[] 30 | 31 | export type RequestHandler = ( 32 | req: express.Request, 33 | res: express.Response, 34 | next: express.NextFunction, 35 | ) => Promise 36 | 37 | export function createMiddlewareRequestHandler({ 38 | build, 39 | getLoadContext, 40 | mode = process.env.NODE_ENV, 41 | }: { 42 | build: ServerBuild | (() => Promise) 43 | getLoadContext?: GetLoadContextFunction 44 | mode?: string 45 | }): RequestHandler { 46 | const handleRemixRequest = createRemixRequestHandler(build, mode) 47 | return async ( 48 | req: express.Request, 49 | res: express.Response, 50 | expressNext: express.NextFunction, 51 | ) => { 52 | try { 53 | const request = createRemixRequest(req, res) 54 | 55 | // need special handling for data requests so middleware functions 56 | // don't see special data urls 57 | let url = new URL(req.url, 'http://localhost') 58 | let isDataRequest = url.pathname.endsWith('.data') || url.searchParams.has('_data') 59 | let isRootData = url.pathname === '/_root.data' 60 | if (isDataRequest) { 61 | // rebuild url without .data or index query param 62 | url.searchParams.delete('index') 63 | url.searchParams.delete('_data') 64 | url = new URL( 65 | (isRootData ? '/' : url.pathname.replace(/\.data$/, '')) + url.search, 66 | 'http://localhost', 67 | ) 68 | req.originalUrl = url.pathname + url.search 69 | req.url = req.originalUrl 70 | } 71 | // separate request for middleware functions 72 | const middlewareRequest = createMiddlewareRequest(req, res) 73 | 74 | // setup server context 75 | const context = ((await getLoadContext?.(req, res)) ?? 76 | {}) as AppLoadContext & ServerContext 77 | context.set = contextSet 78 | context.get = contextGet 79 | 80 | // match routes to determine which middleware to run 81 | const routes = getRoutes() 82 | 83 | // @ts-expect-error routes type 84 | let matches = matchRoutes(routes, req.url) ?? [] // get matches for the url 85 | let leafMatch = matches.at(-1) 86 | 87 | const middleware = 88 | matches 89 | // @ts-expect-error route module 90 | .filter(match => match.route?.module['middleware']) 91 | .flatMap( 92 | // @ts-expect-error route module 93 | match => match.route?.module['middleware'] as unknown as Middleware, 94 | ) ?? [] 95 | 96 | let index = 0 97 | let lastCaughtResponse 98 | let lastCaughtError 99 | 100 | // eslint-disable-next-line no-inner-declarations 101 | // @ts-ignore-next-line 102 | async function next() { 103 | try { 104 | const fn = middleware[index++] 105 | if (!fn) { 106 | return await handleRemixRequest(request, context) 107 | } 108 | return fn({ 109 | request: middlewareRequest, 110 | params: (leafMatch?.params ?? {}) as Record, 111 | context, 112 | matches, 113 | // @ts-ignore-next-line 114 | next, 115 | }) 116 | } catch (e) { 117 | // stop middleware 118 | index = middleware.length 119 | if (e instanceof Response) { 120 | lastCaughtResponse = e 121 | } else { 122 | lastCaughtError = e 123 | } 124 | } 125 | } 126 | 127 | let response 128 | try { 129 | // start middleware/remix chain 130 | response = await next() 131 | } catch (e) { 132 | if (e instanceof Response) { 133 | lastCaughtResponse = e 134 | } else { 135 | lastCaughtError = e 136 | } 137 | } 138 | if (lastCaughtResponse) { 139 | response = lastCaughtResponse 140 | } 141 | if (lastCaughtError) { 142 | response = Response.json(lastCaughtError, { status: 500 }) 143 | } 144 | 145 | if (!response) { 146 | throw new Error('Middleware must return the Response from next()') 147 | } 148 | 149 | const isRedirect = isRedirectResponse(response) 150 | if (isDataRequest && isRedirect) { 151 | const status = response.status 152 | const location = response.headers.get('Location') 153 | // HACK to get correct turbo-stream response 154 | // I'll figure this out later 155 | let body = `[["SingleFetchRedirect",1],{"2":3,"4":5,"6":7,"8":7},"redirect","${location}","status",${status},"revalidate",false,"reload"]` 156 | 157 | response = new Response(body, { 158 | status: 200, 159 | headers: (response as unknown as Response).headers, 160 | }) 161 | response.headers.set('Content-Type', 'text/x-turbo; charset=utf-8') 162 | response.headers.set('X-Remix-Response', 'yes') 163 | response.headers.delete('Location') 164 | } 165 | 166 | await sendRemixResponse(res, response as unknown as Response) 167 | } catch (error) { 168 | // Express doesn't support async functions, so we have to pass along the 169 | // error manually using next(). 170 | expressNext(error) 171 | } 172 | } 173 | } 174 | 175 | function isRedirectResponse(response: Response) { 176 | return response.status >= 300 && response.status < 400 177 | } 178 | -------------------------------------------------------------------------------- /packages/remix-create-express-app/src/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | import { type Server } from 'node:net' 4 | import url from 'node:url' 5 | import { 6 | createRequestHandler as createExpressRequestHandler, 7 | GetLoadContextFunction as ExpressGetLoadContextFunction, 8 | } from './remix.js' 9 | import { AppLoadContext, type ServerBuild } from '@remix-run/node' 10 | import express, { type Application } from 'express' 11 | import sourceMapSupport from 'source-map-support' 12 | import { createMiddlewareRequestHandler } from './middleware.js' 13 | import { setRoutes } from './routes.js' 14 | import compression from 'compression' 15 | import morgan from 'morgan' 16 | 17 | export type CreateRequestHandlerFunction = typeof createExpressRequestHandler 18 | export type GetLoadContextFunction = ( 19 | req: express.Request, 20 | res: express.Response, 21 | args: { 22 | build: ServerBuild 23 | }, 24 | ) => Promise | AppLoadContext 25 | export type ConfigureFunction = (app: Application) => Promise | void 26 | export type CreateServerFunction = (app: Application) => Server 27 | export type GetExpressFunction = () => Application 28 | 29 | export type CreateExpressAppArgs = { 30 | configure?: ConfigureFunction 31 | getLoadContext?: GetLoadContextFunction 32 | customRequestHandler?: ( 33 | defaultCreateRequestHandler: CreateRequestHandlerFunction, 34 | ) => CreateRequestHandlerFunction 35 | getExpress?: GetExpressFunction 36 | createServer?: CreateServerFunction 37 | unstable_middleware?: boolean 38 | buildDirectory?: string 39 | serverBuildFile?: string 40 | } 41 | 42 | export async function createExpressApp({ 43 | configure, 44 | getLoadContext, 45 | customRequestHandler, 46 | getExpress, 47 | createServer, 48 | unstable_middleware, 49 | buildDirectory = 'build', 50 | serverBuildFile = 'index.js', 51 | }: CreateExpressAppArgs = {}): Promise { 52 | sourceMapSupport.install({ 53 | retrieveSourceMap: function (source) { 54 | const match = source.startsWith('file://') 55 | if (match) { 56 | const filePath = url.fileURLToPath(source) 57 | const sourceMapPath = `${filePath}.map` 58 | if (fs.existsSync(sourceMapPath)) { 59 | return { 60 | url: source, 61 | map: fs.readFileSync(sourceMapPath, 'utf8'), 62 | } 63 | } 64 | } 65 | return null 66 | }, 67 | }) 68 | 69 | const mode = 70 | process.env.NODE_ENV === 'test' ? 'development' : process.env.NODE_ENV 71 | 72 | const isProductionMode = mode === 'production' 73 | 74 | let app = getExpress?.() ?? express() 75 | if (app instanceof Promise) { 76 | app = await app 77 | } 78 | 79 | if (configure) { 80 | // call custom configure function if provided 81 | await configure(app) 82 | } else { 83 | // otherwise setup default middleware similar to remix app server 84 | app.disable('x-powered-by') 85 | app.use(compression()) 86 | app.use(morgan('tiny')) 87 | } 88 | 89 | // Vite fingerprints its assets so we can cache forever. 90 | app.use( 91 | '/assets', 92 | express.static(`${buildDirectory}/client/assets`, { 93 | immutable: true, 94 | maxAge: '1y', 95 | }), 96 | ) 97 | 98 | // Everything else (like favicon.ico) is cached for an hour. You may want to be 99 | // more aggressive with this caching. 100 | app.use( 101 | express.static(isProductionMode ? `${buildDirectory}/client` : 'public', { 102 | maxAge: '1h', 103 | }), 104 | ) 105 | 106 | const defaultCreateRequestHandler = unstable_middleware 107 | ? createMiddlewareRequestHandler 108 | : createExpressRequestHandler 109 | 110 | const createRequestHandler = 111 | customRequestHandler?.( 112 | defaultCreateRequestHandler as CreateRequestHandlerFunction, 113 | ) ?? defaultCreateRequestHandler 114 | 115 | // handle remix requests 116 | app.all( 117 | '*', 118 | async ( 119 | req: express.Request, 120 | res: express.Response, 121 | next: express.NextFunction, 122 | ) => { 123 | const build = isProductionMode 124 | ? await importProductionBuild(buildDirectory, serverBuildFile) 125 | : await importDevBuild() 126 | 127 | const expressGetLoadContextFunction: ExpressGetLoadContextFunction = 128 | async (req, res) => { 129 | let context = getLoadContext?.(req, res, { build }) ?? {} 130 | if (context instanceof Promise) { 131 | context = await context 132 | } 133 | return context 134 | } 135 | 136 | return createRequestHandler({ 137 | build, 138 | mode, 139 | getLoadContext: expressGetLoadContextFunction, 140 | })(req, res, next) 141 | }, 142 | ) 143 | 144 | const port = process.env.PORT ?? 3000 145 | const host = process.env.HOST ?? 'localhost' 146 | 147 | if (isProductionMode) { 148 | // create a custom server if createServer function is provided 149 | let server = createServer?.(app) ?? app 150 | if (server instanceof Promise) { 151 | server = await server 152 | } 153 | // check if server is an https/http2 server 154 | const isSecureServer = !!('cert' in server && server.cert) 155 | 156 | server.listen(port, () => { 157 | const url = new URL(`${isSecureServer ? 'https' : 'http'}://${host}`) 158 | // setting port this way because it will not explicitly set the port 159 | // if it's the default port for the protocol 160 | url.port = String(port) 161 | console.log(`Express server listening at ${url}`) 162 | }) 163 | } 164 | 165 | return app 166 | } 167 | 168 | // This server is only used to load the dev server build 169 | const viteDevServer = 170 | process.env.NODE_ENV === 'production' 171 | ? undefined 172 | : await import('vite').then(vite => 173 | vite.createServer({ 174 | server: { middlewareMode: true }, 175 | appType: 'custom', 176 | }), 177 | ) 178 | 179 | function importProductionBuild( 180 | buildDirectory: string, 181 | serverBuildFile: string, 182 | ) { 183 | return import( 184 | /*@vite-ignore*/ 185 | url 186 | .pathToFileURL( 187 | path.resolve( 188 | path.join( 189 | process.cwd(), 190 | `/${buildDirectory}/server/${serverBuildFile}`, 191 | ), 192 | ), 193 | ) 194 | .toString() 195 | ).then(build => { 196 | setRoutes(build) 197 | return build 198 | }) as Promise 199 | } 200 | 201 | function importDevBuild() { 202 | return viteDevServer 203 | ?.ssrLoadModule('virtual:remix/server-build') 204 | .then(build => { 205 | setRoutes(build as ServerBuild) 206 | return build as ServerBuild 207 | }) as Promise 208 | } 209 | 210 | // Function to create a dummy express Request object 211 | function createDummyRequest( 212 | url: string, 213 | body: any = {}, 214 | params: Record = {}, 215 | query: Record = {}, 216 | headers: Record = {}, 217 | ) { 218 | return { 219 | url, 220 | originalUrl: url, 221 | body, 222 | params, 223 | query, 224 | headers, 225 | get: (header: string) => headers[header], 226 | } as express.Request 227 | } 228 | 229 | // Function to create a dummy express Response object 230 | function createDummyResponse() { 231 | return {} as express.Response 232 | } 233 | -------------------------------------------------------------------------------- /packages/remix-create-express-app/README.md: -------------------------------------------------------------------------------- 1 | # remix-create-express-app 2 | 3 | 4 | 5 | [![All Contributors](https://img.shields.io/badge/all_contributors-4-orange.svg?style=flat-square)](#contributors-) 6 | 7 | 8 | 9 | 10 | 11 | [![All Contributors](https://img.shields.io/badge/all_contributors-2-orange.svg?style=flat-square)](#contributors-) 12 | 13 | 14 | 15 | This package contains a helper function that enables you to create your Express 16 | app directly from you _entry.server.tsx_. Since the Express app is built along 17 | with the rest of your Remix app, you may import app modules as needed. It also 18 | supports Vite HMR via the `remix-express-dev-server` plugin (which is required 19 | for this to function). 20 | 21 | ## Remix Middleware (New in v0.3.0) 22 | 23 | You can now add middleware to your routes. See below for more information. 24 | 25 | ## Installation 26 | 27 | Install the following npm package. NOTE: This is not a dev dependency, as it 28 | creates the Express app used in production. This is why the two packages are 29 | split. This way you can eliminate the dev tooling when building a production 30 | image. 31 | 32 | ```bash 33 | npm install remix-create-express-app 34 | ``` 35 | 36 | ## Configuration 37 | 38 | From your _entry.server.tsx_ file, export the app from `createExpressApp` and 39 | name it `app` or the name you defined in `expressDevServer({exportName})`. 40 | 41 | This helper function works differently depending on the environment. 42 | 43 | For `development`, it creates an Express app that the Vite plugin will load 44 | via `viteDevServer.ssrLoadModule('virtual:remix/server-build'). The actual server 45 | is controlled by Vite, and can be configured via _vite.config.ts_ `server` options. 46 | 47 | For `production`, it will create a standard node HTTP server listening at `HOST:PORT`. 48 | You can customize the production server using the `createServer` option defined 49 | below. 50 | 51 | ### Options 52 | 53 | ```ts 54 | export type CreateExpressAppArgs = { 55 | // configure the app to add additional express middleware 56 | configure?: async (app: Application) => void 57 | 58 | // get the remix AppLoadContext 59 | getLoadContext?: GetLoadContextFunction 60 | 61 | // the helper will automatically setup the remix request handler 62 | // but you can use this to wrap the default handler, for example sentry. 63 | customRequestHandler?: ( 64 | defaultCreateRequestHandler: CreateRequestHandlerFunction, 65 | ) => CreateRequestHandlerFunction 66 | 67 | // by default, it will use a standard express object, but you can override 68 | // it for example to return one that handles http2 69 | getExpress?: async () => Application 70 | 71 | // this function can be used to create an https or http2 server 72 | createServer?: async (app: Application) => Server 73 | 74 | // set to true to use unstable middleware 75 | unstable_middleware?: boolean 76 | 77 | // remix build directory as defined in vite.config https://remix.run/docs/en/main/file-conventions/vite-config#builddirectory 78 | buildDirectory?: string 79 | 80 | // sever build file as defined in vite.config https://remix.run/docs/en/main/file-conventions/vite-config#serverbuildfile 81 | serverBuildFile?: string 82 | } 83 | ``` 84 | 85 | You can add additional Express middleware with the `configure` function. If you 86 | do not provide a function, it will create a default Express app similar to the 87 | Remix App Server. The `configure` function can be async. If so, make sure to 88 | `await createExpressApp()`. 89 | 90 | If you want to set up the Remix `AppLoadContext`, pass in a function to `getLoadContext`. 91 | Modify the `AppLoadContext` interface used in your app. 92 | 93 | Since the Express app is compiled in the same bundle as the rest of your Remix 94 | app, you can import app modules just like you normally would. 95 | 96 | ### Example 97 | 98 | ```ts 99 | // server/index.ts 100 | 101 | import { createExpressApp } from 'remix-create-express-app' 102 | import compression from 'compression' 103 | import morgan from 'morgan' 104 | import { sayHello } from '#app/hello.server.ts' 105 | 106 | // update the AppLoadContext interface used in your app 107 | declare module '@remix-run/node' { 108 | interface AppLoadContext { 109 | sayHello: () => string 110 | } 111 | } 112 | 113 | export const app = createExpressApp({ 114 | configure: app => { 115 | // setup additional express middleware here 116 | app.use(compression()) 117 | app.disable('x-powered-by') 118 | app.use(morgan('tiny')) 119 | }, 120 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 121 | getLoadContext: async (req, res) => { 122 | // custom load context should match the AppLoadContext interface defined above 123 | return { sayHello } 124 | }, 125 | }) 126 | ``` 127 | 128 | ```ts 129 | // app/hello.server.ts 130 | export const sayHello = () => 'Hello, World!' 131 | ``` 132 | 133 | ```ts 134 | // routes/test.tsx 135 | 136 | export async function loader({ context }: LoaderFunctionArgs) { 137 | // get the context provided from `getLoadContext` 138 | return json({ message: context.sayHello() }) 139 | } 140 | ``` 141 | 142 | ## Advanced Configuration 143 | 144 | ### HTTP/2 145 | 146 | ```ts 147 | import http2 from 'node:http2' 148 | import http2Express from 'http2-express-bridge' 149 | 150 | export const app = createExpressApp({ 151 | // ... 152 | getExpress: () => { 153 | // create a custom express app, needed for HTTP/2 154 | return http2Express(express) 155 | }, 156 | createServer: app => { 157 | // create a custom server for production 158 | // use Vite config `server` to customize the dev server 159 | return http2.createSecureServer( 160 | { 161 | key: fs.readFileSync(process.cwd() + '/server/localhost-key.pem'), 162 | cert: fs.readFileSync(process.cwd() + '/server/localhost-cert.pem'), 163 | }, 164 | app, 165 | ) 166 | }, 167 | }) 168 | ``` 169 | 170 | ### Sentry request handler 171 | 172 | ```ts 173 | import { wrapExpressCreateRequestHandler } from '@sentry/remix' 174 | 175 | export const app = createExpressApp({ 176 | // ... 177 | customRequestHandler: defaultCreateRequestHandler => { 178 | // enables you to wrap the default request handler 179 | return process.env.NODE_ENV === 'production' 180 | ? wrapExpressCreateRequestHandler(defaultCreateRequestHandler) 181 | : defaultCreateRequestHandler // use default in dev 182 | }, 183 | }) 184 | ``` 185 | 186 | ## Middleware 187 | 188 | Middleware are functions that are called before Remix calls your loader/action. This 189 | implementation **Unofficial**, but is based on the [Route Middleware RFC](https://github.com/remix-run/remix/discussions/7642). So it should be mostly compatible once Remix implements Middleware 190 | directly. 191 | 192 | This implementation is done strictly in user-land and does not modify the core Remix 193 | library. However, it does require the new _Single Fetch_ API that was introduced 194 | in Remix v2.9. 195 | 196 | In addition, this middleware implementation currently only supports the Express 197 | adapter. Once it stabilizes, I will look at supporting other adapters, namely Vercel 198 | and Cloudflare. 199 | 200 | ### Configuration 201 | 202 | As stated above, you will need to be on Remix v2.9+ and enable `unstable_singleFetch:true` 203 | in your _vite.config.ts_. We also need the `envOnly` plugin from `vite-env-only`. Since 204 | Remix doesn't know about the `middleware` export, it does not know to only include 205 | it in the server build. We'll wrap the export in `serverOnly$` to ensure it only 206 | ends up in the server bundle. 207 | 208 | ```ts 209 | // vite.config.ts 210 | // we'll need serverOnly$ for the middleware export 211 | import envOnly from 'vite-env-only' 212 | 213 | // single fetch requires nativeFetch: true 214 | installGlobals({ nativeFetch: true }) 215 | 216 | export default defineConfig({ 217 | build: { 218 | target: 'esnext', 219 | }, 220 | plugins: [ 221 | expressDevServer(), 222 | // need the vite-env-only plugin for middleware export 223 | envOnly(), 224 | remix({ 225 | // middleware requires unstable_singleFetch: true 226 | future: { unstable_singleFetch: true }, 227 | }), 228 | tsconfigPaths(), 229 | ], 230 | }) 231 | ``` 232 | 233 | You will also need to enable the `unstable_middleware` setting in your `createExpressApp` call. 234 | 235 | ```ts 236 | // entry.server.tsx 237 | 238 | export const app = createExpressApp({ 239 | //... 240 | unstable_middleware: true, 241 | } 242 | ``` 243 | 244 | ### Creating Middleware 245 | 246 | A middleware is any function that has the following signature: 247 | 248 | ```ts 249 | export type MiddlewareFunctionArgs = { 250 | request: Request 251 | params: Record 252 | context: AppLoadContext & ServerContext 253 | matches: ReturnType 254 | next: () => Promise 255 | } 256 | 257 | export type MiddleWareFunction = ( 258 | args: MiddlewareFunctionArgs, 259 | ) => Response | Promise 260 | ``` 261 | 262 | You can have multiple middleware functions for a given route. In your route, export the 263 | `middleware` array of functions. 264 | 265 | ```ts 266 | // routes/some-route.tsx 267 | export middleware = [middleware1, middleware2, middleware3] 268 | ``` 269 | 270 | When a URL is requested, the Express handler will first get the matching routes, 271 | the same way that Remix matches routes. It will get a list of routes from the 272 | root route to the leaf route. 273 | 274 | It then checks each matching route for a `middleware` export. Finally, it combines 275 | all the `middleware` arrays for all the matching routes to create a single array 276 | of middleware functions (via `flatMap`). They will then be executed in the order 277 | they were defined from the _root_ to the leaf route. 278 | 279 | NOTE: These middleware functions are executed sequentially, unlike loaders. Once all 280 | the middleware are executed, Remix will then run the matching loaders and actions 281 | in parallel as usual. 282 | 283 | ### ServerContext 284 | 285 | Each middleware function receives the current `context` object initialized by the 286 | `getLoadContext` function in `createExpressApp`. This context object is _mutable_ 287 | and passed along the middleware chain. This way, each middleware function 288 | can add additional data to the context or perform logic based on this data. 289 | 290 | It also contains the `ServerObject` interface, which are two methods to `get` and 291 | `set` the context created by `createContext` 292 | 293 | ```ts 294 | export type ServerContext = { 295 | get: (contextType: ContextType) => T 296 | set: (contextType: ContextType, value: T) => void 297 | } 298 | 299 | function createContext(): ContextType 300 | ``` 301 | 302 | When defining middleware that uses context, you can create a new context object 303 | by calling `createContext`. This context object is passed to by the `set` and `get` 304 | methods on the ServerContext object passed to both the middleware functions and 305 | your loaders and actions. 306 | 307 | ```ts 308 | const UserContext = createContext() 309 | 310 | // inside middleware 311 | async function userMiddleware({ request, context }: MiddlewareFunctionArgs) { 312 | const cookies = cookie.parse(request.headers.get('Cookie') ?? '') 313 | const user = await getUserFromCookie(cookies.user) 314 | // set the user in the context from the cookie 315 | context.set(UserContext, user) 316 | return next() 317 | } 318 | 319 | // inside your loader 320 | async function loader({ context }: LoaderFunctionArgs) { 321 | const user = context.get(UserContext) 322 | // ... 323 | } 324 | ``` 325 | 326 | In addition, the middleware function can inspect the `Request` object. It can also 327 | modify the request by adding or removing headers. 328 | 329 | A middleware function is responsible for calling the `next` function and returning 330 | the response. 331 | 332 | The middleware function can inspect the response and update the headers. This 333 | response is passed back up the middleware chain, and then after the final middleware 334 | function returns, the response is sent to the client. 335 | 336 | Here is an example middleware. It adds the `session` object to the `context`. In 337 | addition to making it easy to access the session, it will also commit 338 | the session if mutated. If you are using the cookie session storage, it will also add 339 | the `set-cookie` header automatically. 340 | 341 | ```ts 342 | // entry.server.tsx 343 | declare module '@remix-run/server-runtime' { 344 | export interface AppLoadContext { 345 | sayHello: () => string 346 | // add session to the context for type safety 347 | session: Session 348 | } 349 | } 350 | 351 | // ------------------- 352 | // root.tsx 353 | import { session } from '#app/middleware/session.ts' 354 | import { serverOnly$ } from 'vite-env-only' 355 | 356 | // export your middleware as array of functions that Remix will call 357 | // wrap middleware in serverOnly$ to prevent it from being bundled in the browser 358 | // since remix doesn't know about middleware yet 359 | export const middleware = serverOnly$([ 360 | session({ isCookieSessionStorage: true }), 361 | ]) 362 | 363 | //--------------------- 364 | // routes/test.tsx 365 | export async function loader({ context }: LoaderFunctionArgs) { 366 | // get the session object directly from context 367 | const count = Number(context.session.get('count') || 0) 368 | 369 | return { message: context.sayHello(), count } 370 | } 371 | 372 | export async function action({ request, context }: ActionFunctionArgs) { 373 | const formData = await request.formData() 374 | if (formData.has('inc')) { 375 | // you should only see set-cookie header when session is modified 376 | const count = Number(context.session.get('count') || 0) 377 | // mutate the session 378 | context.session.set('count', count + 1) 379 | } 380 | throw redirect('/test') 381 | } 382 | ``` 383 | 384 | Here is a more complex chain of middleware 385 | 386 | ```ts 387 | // root.tsx 388 | // multiple middleware executed in sequence 389 | export middleware = [middleware1, middleware2] 390 | 391 | async function middleware1({ request, context, next }: MiddlewareFunctionArgs) { 392 | // modify request headers 393 | request.headers.set('x-some-header', 'root1') 394 | // update context 395 | context.root1 = 'added by root1 middleware' 396 | // call next middleware 397 | const response = await next() 398 | // update response headers 399 | response.headers.set('x-some-other-header', 'root1 middleware') 400 | // return the response 401 | return response 402 | } 403 | 404 | async function middleware2({ request, context, next }: MiddlewareFunctionArgs) { 405 | // get header set by previous middleware 406 | const header1 = request.headers.get('x-some-header') 407 | // add additional request headers 408 | request.headers.set('x-another-header', 'root2') 409 | // update context with more data 410 | context.root2 = 'added by root2 middleware' 411 | // access context data set by previous middleware 412 | const context1 = context.root1 413 | // call next middleware 414 | const response = await next() 415 | // check response status 416 | if (response.status === 404) { 417 | throw redirect('/another-page') // note thrown responses/errors are still WIP 418 | } 419 | // update response headers 420 | response.headers.append('x-yet-another', 'root2 middleware') 421 | // return the response 422 | return response 423 | } 424 | 425 | 426 | //----------------- 427 | // routes/child.tsx 428 | // 429 | export middleware = [middleware3] 430 | 431 | async function middleware3({ request, context, next }: MiddlewareFunctionArgs) { 432 | // do more stuff with request headers and context 433 | 434 | // return the response from next 435 | return await next() 436 | } 437 | ``` 438 | 439 | Here's how the middleware will execute 440 | 441 | ``` 442 | GET /test 443 | --- middleware executed in sequence --- 444 | execute root.middleware1 445 | execute root.middleware2 446 | execute child.middleware3 447 | 448 | --- remix executes loaders in parallel --- 449 | execute root.loader + child.loader 450 | 451 | --- walk back up the chain return response --- 452 | return response from child 453 | return response from root 454 | return response from child.middleware3 455 | return response from root.middleware2 456 | return response from root.middleware1 457 | 458 | --- finally return resopnse to client --- 459 | return response to client 460 | ``` 461 | 462 | ## Contributors ✨ 463 | 464 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 485 | 486 | 487 |
Michael Carter
Michael Carter

💻
Justin Hall
Justin Hall

💻
Levi Thornton
Levi Thornton

💻
Thomas Welton
Thomas Welton

💻
481 | 482 | Add your contributions 483 | 484 |
488 | 489 | 490 | 491 | 492 | 493 | 494 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 495 | --------------------------------------------------------------------------------