diff --git a/examples/remix-unstable-custom-build/.eslintrc.cjs b/examples/remix-unstable-custom-build/.eslintrc.cjs new file mode 100644 index 0000000..0a90d39 --- /dev/null +++ b/examples/remix-unstable-custom-build/.eslintrc.cjs @@ -0,0 +1,80 @@ +/** + * This is intended to be a basic starting point for linting in your app. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + ignorePatterns: ["!**/.server", "!**/.client"], + + // Base config + extends: ["eslint:recommended"], + + overrides: [ + // React + { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: ["react", "jsx-a11y"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ], + settings: { + react: { + version: "detect", + }, + formComponents: ["Form"], + linkComponents: [ + { name: "Link", linkAttribute: "to" }, + { name: "NavLink", linkAttribute: "to" }, + ], + "import/resolver": { + typescript: {}, + }, + }, + }, + + // Typescript + { + files: ["**/*.{ts,tsx}"], + plugins: ["@typescript-eslint", "import"], + parser: "@typescript-eslint/parser", + settings: { + "import/internal-regex": "^~/", + "import/resolver": { + node: { + extensions: [".ts", ".tsx"], + }, + typescript: { + alwaysTryTypes: true, + }, + }, + }, + extends: ["plugin:@typescript-eslint/recommended", "plugin:import/recommended", "plugin:import/typescript"], + }, + + // Node + { + files: [".eslintrc.cjs"], + env: { + node: true, + }, + }, + ], +}; diff --git a/examples/remix-unstable-custom-build/.gitignore b/examples/remix-unstable-custom-build/.gitignore new file mode 100644 index 0000000..ba8ecf4 --- /dev/null +++ b/examples/remix-unstable-custom-build/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/dist +.env diff --git a/examples/remix-unstable-custom-build/README.md b/examples/remix-unstable-custom-build/README.md new file mode 100644 index 0000000..6417d50 --- /dev/null +++ b/examples/remix-unstable-custom-build/README.md @@ -0,0 +1,36 @@ +# Welcome to Remix + Vite! + +📖 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. + +## Development + +Run the Vite dev server: + +```shellscript +npm run dev +``` + +## Deployment + +First, build your app for production: + +```sh +npm run build +``` + +Then run the app in production mode: + +```sh +npm start +``` + +Now you'll need to pick a host to deploy it to. + +### DIY + +If you're familiar with deploying Node applications, the built-in Remix app server is production-ready. + +Make sure to deploy the output of `npm run build` + +- `build/server` +- `build/client` diff --git a/examples/remix-unstable-custom-build/app/entry.client.tsx b/examples/remix-unstable-custom-build/app/entry.client.tsx new file mode 100644 index 0000000..dc318e7 --- /dev/null +++ b/examples/remix-unstable-custom-build/app/entry.client.tsx @@ -0,0 +1,18 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * 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` ✨ + * For more information, see https://remix.run/file-conventions/entry.client + */ + +import { RemixBrowser } from "@remix-run/react"; +import { StrictMode, startTransition } from "react"; +import { hydrateRoot } from "react-dom/client"; + +startTransition(() => { + hydrateRoot( + document, + + + + ); +}); diff --git a/examples/remix-unstable-custom-build/app/entry.server.tsx b/examples/remix-unstable-custom-build/app/entry.server.tsx new file mode 100644 index 0000000..ab9ce28 --- /dev/null +++ b/examples/remix-unstable-custom-build/app/entry.server.tsx @@ -0,0 +1,123 @@ +/** + * By default, Remix will handle generating the HTTP Response for you. + * 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` ✨ + * For more information, see https://remix.run/file-conventions/entry.server + */ + +import { PassThrough } from "node:stream"; +import type { AppLoadContext, EntryContext } from "@remix-run/node"; +import { createReadableStreamFromReadable } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import { isbot } from "isbot"; +import { renderToPipeableStream } from "react-dom/server"; + +export * from "./server"; + +const ABORT_DELAY = 5_000; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + // This is ignored so we can keep it in the template for visibility. Feel + // free to delete this parameter in your app if you're not using it! + // eslint-disable-next-line @typescript-eslint/no-unused-vars + loadContext: AppLoadContext +) { + return isbot(request.headers.get("user-agent") || "") + ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext) + : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext); +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/examples/remix-unstable-custom-build/app/root.tsx b/examples/remix-unstable-custom-build/app/root.tsx new file mode 100644 index 0000000..bc3925b --- /dev/null +++ b/examples/remix-unstable-custom-build/app/root.tsx @@ -0,0 +1,28 @@ +import type { LinksFunction } from "@remix-run/node"; +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react"; +import styles from "~/styles/tailwind.css?url"; + +export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} diff --git a/examples/remix-unstable-custom-build/app/routes/_index.tsx b/examples/remix-unstable-custom-build/app/routes/_index.tsx new file mode 100644 index 0000000..10d1490 --- /dev/null +++ b/examples/remix-unstable-custom-build/app/routes/_index.tsx @@ -0,0 +1,54 @@ +import { type ClientLoaderFunctionArgs, useLoaderData, useRevalidator } from "@remix-run/react"; +import { getPublic } from "~/utils/.client/public"; +import { getCommon } from "~/utils/.common/common"; +import { getSecret } from "~/utils/.server/secret"; +import { getEnv } from "~/utils/env.server"; +import dbLogo from "/images/database.svg"; + +export function loader() { + console.log(getSecret(), getCommon()); + return { + env: getEnv(), + }; +} + +export async function clientLoader({ serverLoader }: ClientLoaderFunctionArgs) { + console.log(getPublic(), getCommon()); + return { + ...(await serverLoader()), + }; +} + +clientLoader.hydrate = true; + +export default function Index() { + const data = useLoaderData(); + console.log(dbLogo); + const { revalidate } = useRevalidator(); + return ( +
+ +
+ + + + + + + + + {Object.entries(data.env).map(([key, value]) => ( + + + + + ))} + +
KeyValue
{key}{value ?? "-"}
+
+
+ ); +} diff --git a/examples/remix-unstable-custom-build/app/server/index.ts b/examples/remix-unstable-custom-build/app/server/index.ts new file mode 100644 index 0000000..76c510e --- /dev/null +++ b/examples/remix-unstable-custom-build/app/server/index.ts @@ -0,0 +1,12 @@ +import { createHonoServer } from "react-router-hono-server/node"; +import { exampleMiddleware } from "./middleware"; + +export const server = await createHonoServer({ + buildDirectory: "dist", + configure(server) { + server.use("*", exampleMiddleware()); + }, + listeningListener(info) { + console.log(`Server is listening on http://localhost:${info.port}`); + }, +}); diff --git a/examples/remix-unstable-custom-build/app/server/middleware.ts b/examples/remix-unstable-custom-build/app/server/middleware.ts new file mode 100644 index 0000000..4a3aba5 --- /dev/null +++ b/examples/remix-unstable-custom-build/app/server/middleware.ts @@ -0,0 +1,8 @@ +import { createMiddleware } from "hono/factory"; + +export function exampleMiddleware() { + return createMiddleware(async (c, next) => { + console.log("accept-language", c.req.header("accept-language")); + return next(); + }); +} diff --git a/examples/remix-unstable-custom-build/app/styles/tailwind.css b/examples/remix-unstable-custom-build/app/styles/tailwind.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/examples/remix-unstable-custom-build/app/styles/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/examples/remix-unstable-custom-build/app/utils/.client/public.ts b/examples/remix-unstable-custom-build/app/utils/.client/public.ts new file mode 100644 index 0000000..04c748c --- /dev/null +++ b/examples/remix-unstable-custom-build/app/utils/.client/public.ts @@ -0,0 +1,3 @@ +export function getPublic() { + return "public"; +} diff --git a/examples/remix-unstable-custom-build/app/utils/.common/common.ts b/examples/remix-unstable-custom-build/app/utils/.common/common.ts new file mode 100644 index 0000000..9665a73 --- /dev/null +++ b/examples/remix-unstable-custom-build/app/utils/.common/common.ts @@ -0,0 +1,3 @@ +export function getCommon() { + return "common"; +} diff --git a/examples/remix-unstable-custom-build/app/utils/.server/secret.ts b/examples/remix-unstable-custom-build/app/utils/.server/secret.ts new file mode 100644 index 0000000..d0a98b4 --- /dev/null +++ b/examples/remix-unstable-custom-build/app/utils/.server/secret.ts @@ -0,0 +1,3 @@ +export function getSecret() { + return "secret"; +} diff --git a/examples/remix-unstable-custom-build/app/utils/env.server.ts b/examples/remix-unstable-custom-build/app/utils/env.server.ts new file mode 100644 index 0000000..a038201 --- /dev/null +++ b/examples/remix-unstable-custom-build/app/utils/env.server.ts @@ -0,0 +1,3 @@ +export function getEnv() { + return { ...process.env }; +} diff --git a/examples/remix-unstable-custom-build/package.json b/examples/remix-unstable-custom-build/package.json new file mode 100644 index 0000000..fd5ed27 --- /dev/null +++ b/examples/remix-unstable-custom-build/package.json @@ -0,0 +1,44 @@ +{ + "name": "remix-unstable-custom-build", + "private": true, + "sideEffects": true, + "type": "module", + "scripts": { + "build": "NODE_ENV=production remix vite:build", + "dev": "vite --host", + "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", + "start": "NODE_ENV=production node ./dist/server/index.js", + "typecheck": "tsc" + }, + "dependencies": { + "@remix-run/node": "^2.11.1", + "@remix-run/react": "^2.11.1", + "isbot": "^4.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-hono-server": "*" + }, + "devDependencies": { + "@remix-run/dev": "^2.11.1", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^6.7.4", + "@typescript-eslint/parser": "^6.7.4", + "autoprefixer": "^10.4.20", + "esbuild": "^0.23.1", + "eslint": "^8.38.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "postcss": "^8.4.41", + "tailwindcss": "^3.4.7", + "typescript": "^5.1.6", + "vite": "^5.1.0", + "vite-tsconfig-paths": "^4.2.1" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/examples/remix-unstable-custom-build/postcss.config.js b/examples/remix-unstable-custom-build/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/examples/remix-unstable-custom-build/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/examples/remix-unstable-custom-build/public/favicon.ico b/examples/remix-unstable-custom-build/public/favicon.ico new file mode 100644 index 0000000..8830cf6 Binary files /dev/null and b/examples/remix-unstable-custom-build/public/favicon.ico differ diff --git a/examples/remix-unstable-custom-build/public/images/database.svg b/examples/remix-unstable-custom-build/public/images/database.svg new file mode 100644 index 0000000..e052da1 --- /dev/null +++ b/examples/remix-unstable-custom-build/public/images/database.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/examples/remix-unstable-custom-build/tailwind.config.ts b/examples/remix-unstable-custom-build/tailwind.config.ts new file mode 100644 index 0000000..f4bdc80 --- /dev/null +++ b/examples/remix-unstable-custom-build/tailwind.config.ts @@ -0,0 +1,11 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: ["./app/**/*.{js,jsx,ts,tsx}"], + theme: { + extend: {}, + }, + plugins: [], +}; + +export default config; diff --git a/examples/remix-unstable-custom-build/tsconfig.json b/examples/remix-unstable-custom-build/tsconfig.json new file mode 100644 index 0000000..9d87dd3 --- /dev/null +++ b/examples/remix-unstable-custom-build/tsconfig.json @@ -0,0 +1,32 @@ +{ + "include": [ + "**/*.ts", + "**/*.tsx", + "**/.server/**/*.ts", + "**/.server/**/*.tsx", + "**/.client/**/*.ts", + "**/.client/**/*.tsx" + ], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["@remix-run/node", "vite/client"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Vite takes care of building everything, not tsc. + "noEmit": true + } +} diff --git a/examples/remix-unstable-custom-build/vite.config.ts b/examples/remix-unstable-custom-build/vite.config.ts new file mode 100644 index 0000000..4695388 --- /dev/null +++ b/examples/remix-unstable-custom-build/vite.config.ts @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import { vitePlugin as remix } from "@remix-run/dev"; +import { installGlobals } from "@remix-run/node"; +import esbuild from "esbuild"; +import { devServer } from "react-router-hono-server/dev"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +installGlobals({ nativeFetch: true }); + +export default defineConfig({ + build: { + target: "esnext", + }, + plugins: [ + devServer(), + remix({ + buildDirectory: "dist", + future: { + v3_singleFetch: true, + }, + // For Sentry instrumentation + // https://docs.sentry.io/platforms/javascript/guides/remix/manual-setup/#custom-express-server + // buildEnd: async ({ remixConfig }) => { + // const sentryInstrument = `instrument.server`; + // await esbuild + // .build({ + // alias: { + // "~": "./app", + // }, + // outdir: `${remixConfig.buildDirectory}/server`, + // entryPoints: [`${remixConfig.appDirectory}/server/${sentryInstrument}.ts`], + // platform: "node", + // format: "esm", + // // Don't include node_modules in the bundle + // packages: "external", + // bundle: true, + // logLevel: "info", + // }) + // .then(() => { + // const serverBuildPath = `${remixConfig.buildDirectory}/server/${remixConfig.serverBuildFile}`; + // fs.writeFileSync( + // serverBuildPath, + // Buffer.concat([ + // Buffer.from(`import "./${sentryInstrument}.js"\n`), + // Buffer.from(fs.readFileSync(serverBuildPath)), + // ]) + // ); + // }) + // .catch((error: unknown) => { + // console.error(error); + // process.exit(1); + // }); + // }, + }), + tsconfigPaths(), + ], +}); diff --git a/package-lock.json b/package-lock.json index ea19d6e..023469a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-router-hono-server", - "version": "0.3.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "react-router-hono-server", - "version": "0.3.0", + "version": "0.5.0", "license": "ISC", "workspaces": [ ".", @@ -102,6 +102,39 @@ "node": ">=18.0.0" } }, + "examples/remix-unstable-custom-build": { + "dependencies": { + "@remix-run/node": "^2.11.1", + "@remix-run/react": "^2.11.1", + "isbot": "^4.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-hono-server": "*" + }, + "devDependencies": { + "@remix-run/dev": "^2.11.1", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^6.7.4", + "@typescript-eslint/parser": "^6.7.4", + "autoprefixer": "^10.4.20", + "esbuild": "^0.23.1", + "eslint": "^8.38.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "postcss": "^8.4.41", + "tailwindcss": "^3.4.7", + "typescript": "^5.1.6", + "vite": "^5.1.0", + "vite-tsconfig-paths": "^4.2.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -11462,6 +11495,10 @@ "resolved": "examples/remix-unstable", "link": true }, + "node_modules/remix-unstable-custom-build": { + "resolved": "examples/remix-unstable-custom-build", + "link": true + }, "node_modules/require-like": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz", diff --git a/package.json b/package.json index bf9a9d6..863eb3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-router-hono-server", - "version": "0.4.0", + "version": "0.5.0", "description": "This package includes helper function to create an Hono app in your entry.server.tsx file. It allows you to customize your server.", "exports": { "./node": { diff --git a/src/node.ts b/src/node.ts index 9f728c2..295cd55 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,3 +1,4 @@ +import type { AddressInfo } from "node:net"; import path from "node:path"; import url from "node:url"; import { serve } from "@hono/node-server"; @@ -27,7 +28,7 @@ export type HonoServerOptions = { /** * The directory where the server build files are located (defined in vite.config) * - * Defaults to `build/server` + * Defaults to `build` * * See https://remix.run/docs/en/main/file-conventions/vite-config#builddirectory */ @@ -78,7 +79,7 @@ export type HonoServerOptions = { * * Defaults log the port */ - listeningListener?: (info: { port: number }) => void; + listeningListener?: (info: AddressInfo) => void; /** * Hono constructor options * @@ -90,7 +91,7 @@ export type HonoServerOptions = { const defaultOptions: HonoServerOptions = { defaultLogger: true, port: Number(process.env.PORT) || 3000, - buildDirectory: "build/server", + buildDirectory: "build", serverBuildFile: "index.js", assetsDir: "assets", listeningListener: (info) => { @@ -116,19 +117,23 @@ export async function createHonoServer(options: HonoSe const server = new Hono(mergedOptions.honoOptions); + const serverBuildPath = `./${mergedOptions.buildDirectory}/server`; + + const clientBuildPath = `./${mergedOptions.buildDirectory}/client`; + /** * Serve assets files from build/client/assets */ server.use( `/${mergedOptions.assetsDir}/*`, cache(60 * 60 * 24 * 365), // 1 year - serveStatic({ root: "./build/client" }) + serveStatic({ root: clientBuildPath }) ); /** * Serve public files */ - server.use("*", cache(60 * 60), serveStatic({ root: isProductionMode ? "./build/client" : "./public" })); // 1 hour + server.use("*", cache(60 * 60), serveStatic({ root: isProductionMode ? clientBuildPath : "./public" })); // 1 hour /** * Add logger middleware @@ -155,9 +160,7 @@ export async function createHonoServer(options: HonoSe /* @vite-ignore */ url .pathToFileURL( - path.resolve( - path.join(process.cwd(), `./${mergedOptions.buildDirectory}/${mergedOptions.serverBuildFile}`) - ) + path.resolve(path.join(process.cwd(), `${serverBuildPath}/${mergedOptions.serverBuildFile}`)) ) .toString() )