diff --git a/Dockerfile b/Dockerfile index 89c8a02..40e44bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,47 +1,30 @@ -# base node image -FROM node:20-alpine as base - -# set for base and all layer that inherit from it -ENV NODE_ENV production - -# Install all node_modules, including dev dependencies -FROM base as deps - -WORKDIR /myapp - -ADD package.json package-lock.json .npmrc ./ -RUN npm install --production=false - -# Setup production node_modules -FROM base as production-deps - -WORKDIR /myapp - -COPY --from=deps /myapp/node_modules /myapp/node_modules -ADD package.json package-lock.json .npmrc ./ -RUN npm prune --production - -# Build the app -FROM base as build - -WORKDIR /myapp - -COPY --from=deps /myapp/node_modules /myapp/node_modules - -ADD . . +FROM node:20-alpine AS development-dependencies-env +COPY . /app +WORKDIR /app +RUN npm ci + +FROM node:20-alpine AS production-dependencies-env +COPY ./package.json package-lock.json .npmrc /app/ +WORKDIR /app +RUN npm ci --omit=dev + +FROM node:20-alpine AS build-env +COPY . /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app RUN npm run build -# Finally, build the production image with minimal footprint -FROM base +FROM node:20-alpine +COPY ./package.json package-lock.json server.js /app/ + ENV PORT="8080" ENV NODE_ENV="production" -WORKDIR /myapp +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +COPY --from=build-env /app/start.sh /app/start.sh -COPY --from=production-deps /myapp/node_modules /myapp/node_modules -COPY --from=build /myapp/build /myapp/build -COPY --from=build /myapp/package.json /myapp/package.json -COPY --from=build /myapp/start.sh /myapp/start.sh -CMD ["npm", "start"] +WORKDIR /app +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 612e9a3..88d80d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,14 @@ "dependencies": { "@docsearch/css": "^3.8.0", "@docsearch/react": "^3.8.0", - "@react-router/node": "7.0.1", - "@react-router/serve": "7.0.1", + "@react-router/express": "^7.0.2", + "@react-router/node": "^7.0.1", + "@types/express": "^5.0.0", "cheerio": "^1.0.0-rc.12", "classnames": "^2.3.2", + "compression": "^1.7.5", "escape-goat": "^4.0.0", + "express": "^4.21.2", "follow-redirects": "^1.15.2", "front-matter": "^4.0.2", "gray-matter": "^4.0.3", @@ -21,6 +24,7 @@ "iso-639-1": "^2.1.15", "lodash.merge": "^4.6.2", "lru-cache": "^7.18.3", + "morgan": "^1.10.0", "octokit": "^2.0.14", "parse-link-header": "^2.0.0", "parse-numeric-range": "^1.3.0", @@ -47,10 +51,12 @@ "@react-router/dev": "7.0.1", "@testing-library/jest-dom": "^5.16.5", "@types/eslint": "^8.56.6", + "@types/express-serve-static-core": "^5.0.2", "@types/follow-redirects": "^1.14.1", "@types/gunzip-maybe": "^1.4.0", "@types/hast": "^2.3.4", "@types/lodash.merge": "^4.6.7", + "@types/morgan": "^1.9.9", "@types/node": "^18.16.3", "@types/parse-link-header": "^2.0.1", "@types/react": "^19.0.1", @@ -1891,19 +1897,19 @@ } }, "node_modules/@react-router/express": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.0.1.tgz", - "integrity": "sha512-zAw65RMiF5TshPVvmoCpUjjpNwC7vmpp38lqP2xX6Rfp+K99LhMaNyhsIeG9pKNIXQ7EuMwp3zG7kjr5suQAiA==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.0.2.tgz", + "integrity": "sha512-rhKt/bylEdZNHKzOI8NzP6b27fiJ2zjf59b/boWWOwjuDk6ZEdV1iLa2Nvm55qN1mj2zSx3H/9Tx/ZPHgrGEgw==", "license": "MIT", "dependencies": { - "@react-router/node": "7.0.1" + "@react-router/node": "7.0.2" }, "engines": { "node": ">=20.0.0" }, "peerDependencies": { "express": "^4.17.1", - "react-router": "7.0.1", + "react-router": "7.0.2", "typescript": "^5.1.0" }, "peerDependenciesMeta": { @@ -1912,10 +1918,10 @@ } } }, - "node_modules/@react-router/node": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.0.1.tgz", - "integrity": "sha512-09AkG1jobbMApTx4Wx8lf1u+nZeYG2ZOBAhxTLz7dgnr7sQpUVD3ewe9gizedXpJrCfP4Sx272aGywX1gEqoSQ==", + "node_modules/@react-router/express/node_modules/@react-router/node": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.0.2.tgz", + "integrity": "sha512-6Of5M2wP9QgYlR+boR0ptPjh3UyfaNvPMKQihowTGjAjUZIoNqz4iBn8ClNsLFbT3KQewcnNTHi2p+Ou7S4ZyQ==", "license": "MIT", "dependencies": { "@mjackson/node-fetch-server": "^0.2.0", @@ -1927,7 +1933,7 @@ "node": ">=20.0.0" }, "peerDependencies": { - "react-router": "7.0.1", + "react-router": "7.0.2", "typescript": "^5.1.0" }, "peerDependenciesMeta": { @@ -1936,28 +1942,28 @@ } } }, - "node_modules/@react-router/serve": { + "node_modules/@react-router/node": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.0.1.tgz", - "integrity": "sha512-JkU9aCtj61P7o+cZt+4NWHpij7c71jBnj/lF06zGIp+4VP9q6hUN3pMjdLZe91mBZu6dfmWUTh8hlY6pCoeUIA==", + "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.0.1.tgz", + "integrity": "sha512-09AkG1jobbMApTx4Wx8lf1u+nZeYG2ZOBAhxTLz7dgnr7sQpUVD3ewe9gizedXpJrCfP4Sx272aGywX1gEqoSQ==", "license": "MIT", "dependencies": { - "@react-router/express": "7.0.1", - "@react-router/node": "7.0.1", - "compression": "^1.7.4", - "express": "^4.19.2", - "get-port": "5.1.1", - "morgan": "^1.10.0", - "source-map-support": "^0.5.21" - }, - "bin": { - "react-router-serve": "bin.js" + "@mjackson/node-fetch-server": "^0.2.0", + "source-map-support": "^0.5.21", + "stream-slice": "^0.1.2", + "undici": "^6.19.2" }, "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "react-router": "7.0.1" + "react-router": "7.0.1", + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@rollup/rollup-android-arm-eabi": { @@ -2355,12 +2361,31 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/btoa-lite": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.2.tgz", "integrity": "sha512-ZYbcE2x7yrvNFJiU7xJGrpF/ihpkM7zKgw8bha3LNJSesvTtUNxbpzaT7WXBIryf6jovisrxTBvymxMeLLj1Mg==", "license": "MIT" }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -2394,6 +2419,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", + "integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.2.tgz", + "integrity": "sha512-vluaspfvWEtE4vcSDlKRNer52DvOGrB2xv6diXy6UKyKW0lqZiWHGNApSyxOv+8DE5Z27IzVvE7hNkxg7EXIcg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/follow-redirects": { "version": "1.14.4", "resolved": "https://registry.npmjs.org/@types/follow-redirects/-/follow-redirects-1.14.4.tgz", @@ -2424,6 +2473,12 @@ "@types/unist": "^2" } }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2502,6 +2557,22 @@ "@types/lodash": "*" } }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/morgan": { + "version": "1.9.9", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.9.tgz", + "integrity": "sha512-iRYSDKVaC6FkGSpEVVIvrRGw0DfJMiQzIn3qr2G5B3C//AWkulhXgaBd7tS9/J79GWSYMTHGs7PfI5b3Y8m+RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ms": { "version": "0.7.34", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", @@ -2524,6 +2595,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/qs": { + "version": "6.9.17", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", + "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.1.tgz", @@ -2551,6 +2634,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -5978,9 +6082,9 @@ } }, "node_modules/express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -6002,7 +6106,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -6017,6 +6121,10 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/cookie": { @@ -6506,18 +6614,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-port": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", - "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -13290,9 +13386,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, "node_modules/pathe": { diff --git a/package.json b/package.json index fd93ebc..7abf584 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,13 @@ "sideEffects": false, "type": "module", "scripts": { - "dev": "react-router dev", + "dev": "cross-env NODE_ENV=development node server.js", "build": "react-router build", "format": "prettier --write . && npm run lint:fix", "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", "lint:fix": "npm run lint -- --fix", - "start": "react-router-serve ./build/server/index.js", - "preview": "cross-env NODE_ENV=production dotenv react-router-serve ./build/server/index.js", + "start": "node server.js", + "preview": "dotenv node server.js", "test": "vitest", "typecheck": "react-router typegen && tsc", "push:stage": "git tag -f stage && git push origin stage -f" @@ -23,11 +23,14 @@ "dependencies": { "@docsearch/css": "^3.8.0", "@docsearch/react": "^3.8.0", - "@react-router/node": "7.0.1", - "@react-router/serve": "7.0.1", + "@react-router/express": "^7.0.2", + "@react-router/node": "^7.0.1", + "@types/express": "^5.0.0", "cheerio": "^1.0.0-rc.12", "classnames": "^2.3.2", + "compression": "^1.7.5", "escape-goat": "^4.0.0", + "express": "^4.21.2", "follow-redirects": "^1.15.2", "front-matter": "^4.0.2", "gray-matter": "^4.0.3", @@ -36,6 +39,7 @@ "iso-639-1": "^2.1.15", "lodash.merge": "^4.6.2", "lru-cache": "^7.18.3", + "morgan": "^1.10.0", "octokit": "^2.0.14", "parse-link-header": "^2.0.0", "parse-numeric-range": "^1.3.0", @@ -62,10 +66,12 @@ "@react-router/dev": "7.0.1", "@testing-library/jest-dom": "^5.16.5", "@types/eslint": "^8.56.6", + "@types/express-serve-static-core": "^5.0.2", "@types/follow-redirects": "^1.14.1", "@types/gunzip-maybe": "^1.4.0", "@types/hast": "^2.3.4", "@types/lodash.merge": "^4.6.7", + "@types/morgan": "^1.9.9", "@types/node": "^18.16.3", "@types/parse-link-header": "^2.0.1", "@types/react": "^19.0.1", diff --git a/server.js b/server.js new file mode 100644 index 0000000..2f65aa7 --- /dev/null +++ b/server.js @@ -0,0 +1,48 @@ +import compression from "compression"; +import express from "express"; +import morgan from "morgan"; + +// Short-circuit the type-checking of the built output. +const BUILD_PATH = "./build/server/index.js"; +const DEVELOPMENT = process.env.NODE_ENV === "development"; +const PORT = Number.parseInt(process.env.PORT || "3000"); + +const app = express(); + +app.use(compression()); +app.disable("x-powered-by"); + +if (DEVELOPMENT) { + console.log("Starting development server"); + const viteDevServer = await import("vite").then((vite) => + vite.createServer({ + server: { middlewareMode: true }, + }) + ); + app.use(viteDevServer.middlewares); + app.use(async (req, res, next) => { + try { + const source = await viteDevServer.ssrLoadModule("./server/app.ts"); + return await source.app(req, res, next); + } catch (error) { + if (typeof error === "object" && error instanceof Error) { + viteDevServer.ssrFixStacktrace(error); + } + next(error); + } + }); +} else { + console.log("Starting production server"); + app.use( + "/assets", + express.static("build/client/assets", { immutable: true, maxAge: "1y" }) + ); + app.use(express.static("build/client", { maxAge: "1h" })); + app.use(await import(BUILD_PATH).then((mod) => mod.app)); +} + +app.use(morgan("tiny")); + +app.listen(PORT, () => { + console.log(`Server is running on http://localhost:${PORT}`); +}); diff --git a/server/app.ts b/server/app.ts new file mode 100644 index 0000000..76c3ef1 --- /dev/null +++ b/server/app.ts @@ -0,0 +1,11 @@ +import { createRequestHandler } from "@react-router/express"; +import express from "express"; + +export const app = express(); + +app.use( + createRequestHandler({ + // @ts-expect-error - virtual module provided by React Router at build time + build: () => import("virtual:react-router/server-build"), + }) +); diff --git a/vite.config.ts b/vite.config.ts index 5b1e786..939e0aa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,14 +2,16 @@ import { reactRouter } from "@react-router/dev/vite"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; -export default defineConfig({ +export default defineConfig(({ isSsrBuild }) => ({ + build: { + rollupOptions: isSsrBuild + ? { + input: "./server/app.ts", + } + : undefined, + }, ssr: { noExternal: ["@docsearch/react"], }, - server: { - port: 3000, - host: "0.0.0.0", - }, - plugins: [reactRouter(), tsconfigPaths()], -}); +}));