diff --git a/.eslintrc b/.eslintrc index e414272..b440d7b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -21,9 +21,10 @@ "rules": { "no-console": "off", "semi": ["error", "always"], - "@typescript-eslint/explicit-function-return-type": "off", // Optional, customize TypeScript specific rules - "@typescript-eslint/no-unused-vars": ["error"], // Example: detect unused variables in TypeScript - "@typescript-eslint/no-explicit-any": "warn" // Discourages usage of 'any' in TypeScript + "react/react-in-jsx-scope": "off", // Not needed with React 17+ new JSX transform + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-unused-vars": ["error"], + "@typescript-eslint/no-explicit-any": "warn" }, "settings": { "react": { diff --git a/README.md b/README.md index 79a01a1..e24b2b4 100755 --- a/README.md +++ b/README.md @@ -1,27 +1,98 @@ # Devnotnull.com React application -Last updated - 26/08/2022 +Last updated - 01/02/2026 [![On merge to main](https://github.com/devisnotnull/devnotnull.com-app/actions/workflows/on_merge_main.yml/badge.svg)](https://github.com/devisnotnull/devnotnull.com-app/actions/workflows/on_merge_main.yml) Screenshot 2022-05-24 at 22 11 38 -An isomorphic web app which can be run using three distict deployment paradigms +An isomorphic web app which can be run using three distinct deployment paradigms: - Client only bundle - Deployed via Docker -- Deployed via the Serverless framework (AWS Lambda) +- Deployed via the Serverless framework (AWS Lambda) + +## Tech Stack + +- **Build Tool**: Vite 5 +- **Framework**: React 18 +- **Routing**: React Router 6 (with SSR support) +- **Styling**: Tailwind CSS +- **Language**: TypeScript +- **Runtime**: Node.js / Bun + +## Requirements + +- **Node.js**: v20.0.0 or higher +- **Bun**: v1.0.0 or higher (used as package manager) ## Running the application -### Native (Docker) +### Development + +```bash +bun install +bun dev +``` + +This starts: +- Vite dev server on port 9000 (client HMR) +- Server build in watch mode +- Express server on port 3000 + +### Production Build + +```bash +bun build:prod +``` + +### Production (Docker) + +```bash +bun build:prod +docker build -t devnotnull-ui . +docker run -p 3000:3000 devnotnull-ui +``` + +## Project Structure + +``` +src/ +├── client/ # Client entry point +├── server/ # Express server with SSR +├── components/ # React components +├── core/ # Business logic & API calls +├── models/ # TypeScript/Zod schemas +├── page/ # Route page components +├── style/ # Tailwind CSS styles +└── routes.tsx # Route definitions +``` + +## Build Output -yarn prod:pure +``` +build/ +├── server.js # SSR server bundle +├── server.js.map # Source map +└── static/ + ├── client-manifest.json # Vite manifest + ├── css/ # CSS bundles + └── js/ # JS bundles +``` -### Serverless +## Scripts -yarn prod +| Script | Description | +|--------|-------------| +| `bun dev` | Start development server | +| `bun build` | Build client and server | +| `bun build:prod` | Production build | +| `bun build:client` | Build client only | +| `bun build:server` | Build server only | +| `bun lint` | Run ESLint | +| `bun lint:fix` | Fix ESLint issues | +| `bun test` | Run tests | ## Deploy -The deployment pipeline depends on your choice of deployment paradigms +The deployment pipeline depends on your choice of deployment paradigms. diff --git a/bun.lockb b/bun.lockb index 6e46342..9b91bfe 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..e11b683 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + DevNotNull + + +
+ + + diff --git a/package.json b/package.json index d14ec8b..2996a04 100644 --- a/package.json +++ b/package.json @@ -2,36 +2,24 @@ "name": "@devnotnull/devnotnull-ui", "version": "1.0.0", "private": true, + "type": "module", "scripts": { "cleanup": "rimraf build/", "test": "jest", - "build:client:dev": "TARGET=client webpack", - "build:client:prod": "TARGET=client NODE_ENV=production webpack", - "build:server": "TARGET=server webpack", - "build:server:prod": "NODE_ENV=production TARGET=server webpack", - "build": "bun build:client:dev&& bun build:server", - "build:dev": "bun cleanup && NODE_ENV=development bun build:client:dev&& bun build:server", - "build:prod": "bun cleanup && NODE_ENV=production bun build:client:dev&& NODE_ENV=production bun build:server", - "dev": "bun cleanup && bun build:style-typings && NODE_ENV=development IS_LOCAL=TRUE NODE_RUNTIME_ENV=local NODE_ENV=development concurrently -r --kill-others \"bun devserver\" \"TARGET=server webpack --watch\" \"wait-on ./build/server.js && node build/server.js\"", - "prod": "bun cleanup && bun build:style-typings && bun build:client:prod && NODE_ENV=production IS_LOCAL=TRUE NODE_RUNTIME_ENV=local NODE_ENV=production concurrently -r --kill-others \"bun prodserver\" \"TARGET=server webpack --watch\" \"wait-on ./build/server.js && node build/server.js\"", - "build:style-typings": "node webpack/style-typings.js", + "dev": "bun cleanup && concurrently -r --kill-others \"vite\" \"vite build --config vite.config.server.ts --watch\" \"wait-on ./build/server.js && node --experimental-specifier-resolution=node build/server.js\"", + "build": "bun cleanup && bun build:client && bun build:server", + "build:client": "vite build", + "build:server": "vite build --config vite.config.server.ts", + "build:prod": "bun cleanup && NODE_ENV=production bun build:client && NODE_ENV=production bun build:server", + "preview": "vite preview", "lint": "eslint ./src --ext .ts,.tsx", "lint:fix": "eslint --ext .ts,.tsx --fix ./src", "types": "tcm src", - "devserver": "cross-env NODE_ENV=development TARGET=client webpack serve --mode development --inline --hot", - "prodserver": "cross-env NODE_ENV=production TARGET=client webpack serve --mode production", "prepare": "husky install", "infra": "cdk deploy --require-approval never", "infra:synth": "cdk synth" }, "devDependencies": { - "@babel/cli": "^7.22.5", - "@babel/core": "^7.22.5", - "@babel/plugin-transform-react-inline-elements": "^7.22.5", - "@babel/preset-env": "^7.22.5", - "@babel/preset-react": "^7.22.5", - "@babel/preset-typescript": "^7.22.5", - "@babel/register": "^7.18.9", "@testing-library/react": "^13.4.0", "@types/aws-lambda": "^8.10.46", "@types/classnames": "^2.2.8", @@ -44,89 +32,37 @@ "@types/react-syntax-highlighter": "^13.5.2", "@types/serialize-javascript": "^1.5.0", "@types/source-map-support": "^0.5.1", - "@types/webpack": "^4.41.7", - "@types/webpack-dev-middleware": "^2.0.2", - "@types/webpack-env": "^1.13.9", - "@types/webpack-hot-middleware": "^2.16.5", - "@types/webpack-merge": "^5.0.0", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", - "assets-webpack-plugin": "^7.0.0", + "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.14", "aws-sdk": "^2.824.0", - "babel-jest": "^24.0.0", - "babel-loader": "^8.0.6", - "babel-plugin-graphql-tag": "^2.5.0", - "babel-plugin-istanbul": "^4.1.6", - "babel-plugin-module-resolver": "^3.2.0", - "babel-plugin-root-import": "^6.6.0", - "before-build-webpack": "^0.2.11", - "buffer": "^6.0.0", - "circular-dependency-plugin": "^5.2.2", - "concurrently": "^4.1.0", - "copy-webpack-plugin": "^7.0.0", - "cross-env": "^4.0.0", + "concurrently": "^8.2.2", + "cross-env": "^7.0.3", "cross-fetch": "^3.1.4", - "css-loader": "6.8.1", - "css-minimizer-webpack-plugin": "^5.0.1", - "cz-customizable": "^6.2.0", - "duplicate-package-checker-webpack-plugin": "^3.0.0", - "esbuild-loader": "^3.0.1", "eslint": "8.9.0", "eslint-plugin-prettier": "5.2.1", - "extend": "^3.0.1", - "file-loader": "^3.0.1", - "fork-ts-checker-webpack-plugin": "^1.3.5", - "fs-extra": "7.0.0", - "http-server": "^14.1.1", - "https-browserify": "^1.0.0", + "fs-extra": "11.2.0", "husky": "^8.0.0", - "isomorphic-style-loader": "^5.1.0", "jest": "^29.0.3", "jest-environment-jsdom": "^29.7.0", "lint-staged": "^13.2.2", - "mini-css-extract-plugin": "^1.3.3", "nodemon": "^2.0.7", - "os-browserify": "^0.3.0", - "path-browserify": "^1.0.1", "postcss": "^8.4.25", - "postcss-assets": "^5.0.0", - "postcss-custom-media": "^7.0.8", - "postcss-flexbugs-fixes": "^4.1.0", - "postcss-loader": "^7.3.3", - "postcss-media-variables": "^2.0.1", "postcss-preset-env": "^9.0.0", - "postcss-simple-vars": "^6.0.3", - "postcss-url": "^10.1.1", "prettier": "^3.0.3", - "process": "0.11.10", - "redux-devtools-extension": "^2.13.0", - "resolve": "^1.22.2", - "resolve-url-loader": "^3.1.2", - "rimraf": "^2.6.2", + "rimraf": "^5.0.5", "serverless": "^3.33.0", "serverless-deployment-bucket": "1.6.0", "serverless-offline": "12.0.4", "serverless-prune-plugin": "^1.4.3", "serverless-s3-local": "^0.7.2", "serverless-s3-sync": "^1.15.0", - "stream-browserify": "^3.0.0", - "stream-http": "^3.2.0", - "style-loader": "^2.0.0", - "terser-webpack-plugin": "^5.0.3", "ts-jest": "^29.0.1", - "tsconfig-paths-webpack-plugin": "^4.0.1", "typed-css-modules": "^0.7.2", "typescript": "5.1.6", - "url-loader": "^4.1.1", - "wait-on": "^6.0.0", - "webpack": "^5.76.0", - "webpack-cli": "^4.3.1", - "webpack-dev-middleware": "^4.0.2", - "webpack-dev-server": "^3.11.1", - "webpack-manifest-plugin": "^3.0.0", - "webpack-merge": "^4.2.1", - "webpackbar": "^4.0.0" + "vite": "^5.4.11", + "wait-on": "^6.0.0" }, "dependencies": { "@babel/eslint-parser": "^7.25.8", diff --git a/postcss.config.js b/postcss.config.js index 856c311..0781fb4 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,8 +1,9 @@ -const tailwindcss = require('tailwindcss'); +import tailwindcss from 'tailwindcss'; +import postcssPresetEnv from 'postcss-preset-env'; -module.exports = { +export default { plugins: [ - 'postcss-preset-env', - tailwindcss + tailwindcss, + postcssPresetEnv ], -}; \ No newline at end of file +}; diff --git a/src/client/index.tsx b/src/client/index.tsx index ce1223d..7b7c57b 100755 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -1,6 +1,5 @@ -import 'source-map-support/register'; +import '../style/tailwind.css'; -import React from 'react'; import { createRoot } from 'react-dom/client'; import { createBrowserRouter, diff --git a/src/server/http.ts b/src/server/http.ts index 0f22969..f0a68e6 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -1,4 +1,3 @@ -import 'regenerator-runtime/runtime'; import 'source-map-support/register'; import * as Sentry from '@sentry/node'; @@ -9,11 +8,17 @@ import { } from 'react-router-dom/server'; import express, { Express, NextFunction, Request, Response } from 'express'; import { readFile } from 'fs/promises'; +import { existsSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; import compression from 'compression'; import { config } from './config'; import { render } from './render'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + const PROD: boolean = process.env.NODE_ENV === 'production'; const app: Express = express(); @@ -49,6 +54,36 @@ app.use('/static', express.static(__dirname + '/static')); app.use(express.static('static')); app.use(express.static(__dirname + '/static')); +// Helper to load CSS from Vite manifest +const loadCssFromManifest = async (): Promise => { + const manifestPath = resolve(__dirname, 'static/client-manifest.json'); + if (!existsSync(manifestPath)) { + return []; + } + + try { + const manifestContent = await readFile(manifestPath, 'utf-8'); + const manifest = JSON.parse(manifestContent); + const cssFiles: string[] = []; + + for (const entry of Object.values(manifest) as { css?: string[] }[]) { + if (entry.css) { + for (const cssFile of entry.css) { + const cssPath = resolve(__dirname, 'static', cssFile); + if (existsSync(cssPath)) { + const cssContent = await readFile(cssPath, 'utf-8'); + cssFiles.push(cssContent); + } + } + } + } + + return cssFiles; + } catch { + return []; + } +}; + // All other routes will be directed to React app.get('*', async (request: Request, res: Response) => { const { query, dataRoutes } = createStaticHandler(routes); @@ -61,16 +96,8 @@ app.get('*', async (request: Request, res: Response) => { const router = createStaticRouter(dataRoutes, context); - // Load production css if present - // Gross but does the job for now - const assets = __ASSETS__ ?? {}; - const keys = Object.keys(assets); - const css = keys.filter((a) => a.includes('.css') && !a.includes('.map')); - const cssPayload = css.map((key) => assets[key]); - const cssHydrate = cssPayload.map(async (pred) => - (await readFile(`${__dirname}${pred}`)).toString() - ); - const cssHydrated = await Promise.all(cssHydrate); + // Load production css from Vite manifest + const cssHydrated = await loadCssFromManifest(); // Just inline the CSS for better page perf return render(config, router, context, res, cssHydrated); diff --git a/src/style/tailwind.css b/src/style/tailwind.css index 29f1ca2..207f80b 100644 --- a/src/style/tailwind.css +++ b/src/style/tailwind.css @@ -1,12 +1,9 @@ -/* webpackIgnore: true */ +@import "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700,900"; + @tailwind base; -/* webpackIgnore: true */ @tailwind components; -/* webpackIgnore: true */ @tailwind utilities; -@import "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700,900"; - @font-face { font-family: 'HarmanSans'; src: url('https://devnotnull-ui-production.s3.eu-west-2.amazonaws.com/media/Harman-Sans.woff2') format('opentype'); @@ -16,8 +13,8 @@ } @layer base { - html { - font-family: "Roboto", sans-serif; - font-weight: 100; - } -} \ No newline at end of file + html { + font-family: "Roboto", sans-serif; + font-weight: 100; + } +} diff --git a/tailwind.config.js b/tailwind.config.js index c5522b9..198b596 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,15 +1,17 @@ +import tailwindAnimated from 'tailwindcss-animated'; + /** @type {import('tailwindcss').Config} */ -module.exports = { - content: ["./src/**/*.{js,jsx,ts,tsx}"], +export default { + content: ["./src/**/*.{js,jsx,ts,tsx}", "./index.html"], theme: { container: { - screens: { - 'sm': '100%', - 'md': '100%', - 'lg': '900px', - 'xl': '1000px', - '2xl': '1200px', - } + screens: { + 'sm': '100%', + 'md': '100%', + 'lg': '900px', + 'xl': '1000px', + '2xl': '1200px', + } }, fontSize: { xs: ['0.8125rem', { lineHeight: '1.5rem' }], @@ -29,8 +31,8 @@ module.exports = { fontFamily: { harman: ['HarmanSans', 'sans-serif'], }, -}, -plugins: [ - require('tailwindcss-animated') -], -}; \ No newline at end of file + }, + plugins: [ + tailwindAnimated + ], +}; diff --git a/tsconfig.json b/tsconfig.json index df1a1c9..55460f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,9 @@ { "compilerOptions": { "outDir": "dist", - "module": "commonjs", - "target": "es5", - "lib": ["dom", "es2018"], + "module": "ESNext", + "target": "ES2020", + "lib": ["dom", "ES2020", "DOM.Iterable"], "baseUrl": ".", "paths": { "@models/*": ["src/models/*"], @@ -17,9 +17,9 @@ "skipLibCheck": true, "sourceMap": true, "allowJs": true, - "jsx": "react", - "moduleResolution": "node", - "forceConsistentCasingInFileNames": false, + "jsx": "react-jsx", + "moduleResolution": "bundler", + "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noImplicitThis": true, "noImplicitAny": false, @@ -27,16 +27,18 @@ "noUnusedLocals": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, - "experimentalDecorators": true, + "isolatedModules": true, "typeRoots": [ "./typings", "node_modules/@types" - ] + ] }, + "include": ["src"], "exclude": [ "node_modules", - "webpack", + "webpack", "typings", - "dist" + "dist", + "build" ] -} \ No newline at end of file +} diff --git a/vite.config.server.ts b/vite.config.server.ts new file mode 100644 index 0000000..66425fd --- /dev/null +++ b/vite.config.server.ts @@ -0,0 +1,37 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@models': resolve(__dirname, 'src/models'), + '@client': resolve(__dirname, 'src/client'), + '@server': resolve(__dirname, 'src/server'), + '@core': resolve(__dirname, 'src/core'), + '@components': resolve(__dirname, 'src/components'), + '@config': resolve(__dirname, 'src/config'), + '@styles': resolve(__dirname, 'src/style'), + }, + }, + build: { + ssr: true, + outDir: 'build', + emptyOutDir: false, // Don't delete the static folder from client build + rollupOptions: { + input: resolve(__dirname, 'src/server/server.ts'), + output: { + entryFileNames: 'server.js', + format: 'esm', + }, + }, + sourcemap: true, + }, + ssr: { + noExternal: ['react-helmet-async'], + }, + css: { + postcss: './postcss.config.js', + }, +}); diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..5795587 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,46 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@models': resolve(__dirname, 'src/models'), + '@client': resolve(__dirname, 'src/client'), + '@server': resolve(__dirname, 'src/server'), + '@core': resolve(__dirname, 'src/core'), + '@components': resolve(__dirname, 'src/components'), + '@config': resolve(__dirname, 'src/config'), + '@styles': resolve(__dirname, 'src/style'), + }, + }, + build: { + outDir: 'build/static', + manifest: 'client-manifest.json', + rollupOptions: { + input: resolve(__dirname, 'src/client/index.tsx'), + output: { + entryFileNames: 'js/[name].[hash].js', + chunkFileNames: 'js/[name].[hash].js', + assetFileNames: (assetInfo) => { + if (assetInfo.name?.endsWith('.css')) { + return 'css/[name].[hash][extname]'; + } + return 'assets/[name].[hash][extname]'; + }, + }, + }, + sourcemap: true, + }, + css: { + postcss: './postcss.config.js', + }, + server: { + port: 9000, + cors: true, + }, + optimizeDeps: { + include: ['react', 'react-dom', 'react-router-dom'], + }, +});