Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,27 @@
"@mui/material": "^5.11.12",
"@mui/x-date-pickers": "^7.7.1",
"@reduxjs/toolkit": "^2.0.1",
"compression": "^1.7.5",
"dayjs": "^1.11.11",
"express": "^4.21.2",
"formik": "^2.2.9",
"js-cookie": "^3.0.5",
"memory-cache": "^0.2.0",
"qs": "^6.11.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^9.1.0",
"react-router-dom": "^6.23.1",
"serve": "^14.2.3",
"sirv": "^3.0.0",
"yup": "^1.1.1"
},
"devDependencies": {
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^6.2.0",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.2",
"@types/express": "^5.0.0",
"@types/js-cookie": "^3.0.3",
"@types/node": "^20.14.2",
"@types/qs": "^6.9.7",
Expand All @@ -84,6 +89,7 @@
"@testing-library/jest-dom": "^6.2.0",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.2",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.12",
"@types/js-cookie": "^3.0.3",
"@types/node": "^20.14.2",
Expand Down
91 changes: 48 additions & 43 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import { CssBaseline, ThemeProvider } from "@mui/material"
import { type ThemeProviderProps } from "@mui/material/styles/ThemeProvider"
import { useCallback, type FC, type ReactNode } from "react"
import { type FC, type ReactNode } from "react"
import { Provider, type ProviderProps } from "react-redux"
import { BrowserRouter, Routes as RouterRoutes } from "react-router-dom"
import { StaticRouter } from "react-router-dom/server"
import { type Action } from "redux"

import "./App.css"
import { InactiveDialog, ScreenTimeDialog } from "../features"
import { useCountdown, useEventListener, useLocation } from "../hooks"
import { useLocation } from "../hooks"
import { SSR } from "../settings"
// import { InactiveDialog, ScreenTimeDialog } from "../features"
// import { useCountdown, useEventListener } from "../hooks"
// import "../scripts"
// import {
// configureFreshworksWidget,
// toggleOneTrustInfoDisplay,
// } from "../utils/window"

export interface AppProps<A extends Action = Action, S = unknown> {
path?: string
theme: ThemeProviderProps["theme"]
store: ProviderProps<A, S>["store"]
routes: ReactNode
Expand All @@ -26,53 +30,54 @@ export interface AppProps<A extends Action = Action, S = unknown> {
maxTotalSeconds?: number
}

const Routes: FC<
Pick<
AppProps,
"routes" | "header" | "footer" | "headerExcludePaths" | "footerExcludePaths"
>
> = ({
type BaseRoutesProps = Pick<
AppProps,
"routes" | "header" | "footer" | "headerExcludePaths" | "footerExcludePaths"
>

const Routes: FC<BaseRoutesProps & { path: string }> = ({
path,
routes,
header = <></>, // TODO: "header = <Header />"
footer = <></>, // TODO: "footer = <Footer />"
headerExcludePaths = [],
footerExcludePaths = [],
}) => {
}) => (
<>
{!headerExcludePaths.includes(path) && header}
<RouterRoutes>{routes}</RouterRoutes>
{!footerExcludePaths.includes(path) && footer}
</>
)

const BrowserRoutes: FC<BaseRoutesProps> = props => {
const { pathname } = useLocation()

return (
<>
{!headerExcludePaths.includes(pathname) && header}
<RouterRoutes>{routes}</RouterRoutes>
{!footerExcludePaths.includes(pathname) && footer}
</>
)
return <Routes path={pathname} {...props} />
}

const App = <A extends Action = Action, S = unknown>({
path,
theme,
store,
routes,
header,
footer,
headerExcludePaths = [],
footerExcludePaths = [],
maxIdleSeconds = 60 * 60,
maxTotalSeconds = 60 * 60,
...routesProps
}: AppProps<A, S>): JSX.Element => {
const root = document.getElementById("root") as HTMLElement
// TODO: cannot use document during SSR
// const root = document.getElementById("root") as HTMLElement

const [idleSeconds, setIdleSeconds] = useCountdown(maxIdleSeconds)
const [totalSeconds, setTotalSeconds] = useCountdown(maxTotalSeconds)
const resetIdleSeconds = useCallback(() => {
setIdleSeconds(maxIdleSeconds)
}, [setIdleSeconds, maxIdleSeconds])
// const [idleSeconds, setIdleSeconds] = useCountdown(maxIdleSeconds)
// const [totalSeconds, setTotalSeconds] = useCountdown(maxTotalSeconds)
// const resetIdleSeconds = useCallback(() => {
// setIdleSeconds(maxIdleSeconds)
// }, [setIdleSeconds, maxIdleSeconds])

const isIdle = idleSeconds === 0
const tooMuchScreenTime = totalSeconds === 0
// const isIdle = idleSeconds === 0
// const tooMuchScreenTime = totalSeconds === 0

useEventListener(root, "mousemove", resetIdleSeconds)
useEventListener(root, "keypress", resetIdleSeconds)
// useEventListener(root, "mousemove", resetIdleSeconds)
// useEventListener(root, "keypress", resetIdleSeconds)

// React.useEffect(() => {
// configureFreshworksWidget("hide")
Expand All @@ -86,22 +91,22 @@ const App = <A extends Action = Action, S = unknown>({
<ThemeProvider theme={theme}>
<CssBaseline />
<Provider store={store}>
<InactiveDialog open={isIdle} onClose={resetIdleSeconds} />
{/* <InactiveDialog open={isIdle} onClose={resetIdleSeconds} />
<ScreenTimeDialog
open={!isIdle && tooMuchScreenTime}
onClose={() => {
setTotalSeconds(maxTotalSeconds)
}}
/>
<BrowserRouter>
<Routes
routes={routes}
header={header}
footer={footer}
headerExcludePaths={headerExcludePaths}
footerExcludePaths={footerExcludePaths}
/>
</BrowserRouter>
/> */}
{SSR ? (
<StaticRouter location={path as string}>
<Routes path={path as string} {...routesProps} />
</StaticRouter>
) : (
<BrowserRouter>
<BrowserRoutes {...routesProps} />
</BrowserRouter>
)}
</Provider>
</ThemeProvider>
)
Expand Down
181 changes: 181 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* © Ocado Group
* Created on 13/12/2024 at 12:15:05(+00:00).
*
* A server for an app in a live environment.
* Based off: https://github.com/bluwy/create-vite-extra/blob/master/template-ssr-react-ts/server.js
*/

import fs from "node:fs/promises"
import express from "express"
import { Cache } from "memory-cache"

export default class Server {
constructor(
/** @type {Partial<{ mode: "development" | "staging" | "production"; port: number; base: string }>} */
{ mode, port, base } = {},
) {
/** @type {"development" | "staging" | "production"} */
this.mode = mode || process.env.MODE || "development"
/** @type {number} */
this.port = port || (process.env.PORT ? Number(process.env.PORT) : 5173)
/** @type {string} */
this.base = base || process.env.BASE || "/"

/** @type {boolean} */
this.envIsProduction = process.env.NODE_ENV === "production"
/** @type {string} */
this.templateHtml = ""
/** @type {string} */
this.hostname = this.envIsProduction ? "0.0.0.0" : "127.0.0.1"

/** @type {import('express').Express} */
this.app = express()
/** @type {import('vite').ViteDevServer | undefined} */
this.vite = undefined
/** @type {import('memory-cache').Cache<string, any>} */
this.cache = new Cache()

/** @type {string} */
this.healthCheckCacheKey = "health-check"
/** @type {number} */
this.healthCheckCacheTimeout = 30000
/** @type {Record<"healthy" | "startingUp" | "shuttingDown" | "unhealthy" | "unknown", number>} */
this.healthCheckStatusCodes = {
// The app is running normally.
healthy: 200,
// The app is performing app-specific initialisation which must
// complete before it will serve normal application requests
// (perhaps the app is warming a cache or something similar). You
// only need to use this status if your app will be in a start-up
// mode for a prolonged period of time.
startingUp: 503,
// The app is shutting down. As with startingUp, you only need to
// use this status if your app takes a prolonged amount of time
// to shutdown, perhaps because it waits for a long-running
// process to complete before shutting down.
shuttingDown: 503,
// The app is not running normally.
unhealthy: 503,
// The app is not able to report its own state.
unknown: 503,
}
}

/** @type {(request: import('express').Request) => { healthStatus: "healthy" | "startingUp" | "shuttingDown" | "unhealthy" | "unknown"; additionalInfo: string; details?: Array<{ name: string; description: string; health: "healthy" | "startingUp" | "shuttingDown" | "unhealthy" | "unknown" }> }} */
getHealthCheck(request) {
return {
healthStatus: "healthy",
additionalInfo: "All healthy.",
}
}

/** @type {(request: import('express').Request, response: import('express').Response) => void} */
handleHealthCheck(request, response) {
/** @type {{ appId: string; healthStatus: "healthy" | "startingUp" | "shuttingDown" | "unhealthy" | "unknown"; lastCheckedTimestamp: string; additionalInformation: string; startupTimestamp: string; appVersion: string; details: Array<{ name: string; description: string; health: "healthy" | "startingUp" | "shuttingDown" | "unhealthy" | "unknown" }> }} */
let value = this.cache.get(this.healthCheckCacheKey)
if (value === null) {
const healthCheck = this.getHealthCheck(request)

if (healthCheck.healthStatus !== "healthy") {
console.warn(`health check: ${JSON.stringify(healthCheck)}`)
}

value = {
appId: process.env.APP_ID || "REPLACE_ME",
healthStatus: healthCheck.healthStatus,
lastCheckedTimestamp: new Date().toISOString(),
additionalInformation: healthCheck.additionalInfo,
startupTimestamp: new Date().toISOString(),
appVersion: process.env.APP_VERSION || "REPLACE_ME",
details: healthCheck.details || [],
}

this.cache.put(
this.healthCheckCacheKey,
value,
this.healthCheckCacheTimeout,
)
}

response.status(this.healthCheckStatusCodes[value.healthStatus]).json(value)
}

/** @type {(request: import('express').Request, response: import('express').Response) => Promise<void>} */
async handleServeHtml(request, response) {
try {
const path = request.originalUrl.replace(this.base, "")

/** @type {string} */
let template
/** @type {(path: string) => Promise<{ head?: string; html?: string }>} */
let render
if (this.envIsProduction) {
render = (await import("../../../dist/server/entry-server.js")).render

// Use cached template.
template = this.templateHtml
} else {
render = (await this.vite.ssrLoadModule("/src/entry-server.tsx")).render

// Always read fresh template.
template = await fs.readFile("./index.html", "utf-8")
template = await this.vite.transformIndexHtml(path, template)
}

const rendered = await render(path)

const html = template
.replace(`<!--app-head-->`, rendered.head ?? "")
.replace(`<!--app-html-->`, rendered.html ?? "")

response.status(200).set({ "Content-Type": "text/html" }).send(html)
} catch (error) {
this.vite?.ssrFixStacktrace(error)
console.error(error.stack)
response.status(500).end(this.envIsProduction ? undefined : error.stack)
}
}

async run() {
this.app.get("/health-check", (request, response) => {
this.handleHealthCheck(request, response)
})

if (this.envIsProduction) {
const compression = (await import("compression")).default
const sirv = (await import("sirv")).default

this.templateHtml = await fs.readFile("./dist/client/index.html", "utf-8")

this.app.use(compression())
this.app.use(this.base, sirv("./dist/client", { extensions: [] }))
} else {
const { createServer } = await import("vite")

this.vite = await createServer({
server: { middlewareMode: true },
appType: "custom",
base: this.base,
mode: this.mode,
})

this.app.use(this.vite.middlewares)
}

this.app.get("*", async (request, response) => {
await this.handleServeHtml(request, response)
})

this.app.listen(this.port, this.hostname, () => {
let startMessage =
"Server started.\n" +
`url: http://${this.hostname}:${this.port}\n` +
`environment: ${process.env.NODE_ENV}\n`

if (!this.envIsProduction) startMessage += `mode: ${this.mode}\n`

console.log(startMessage)
})
}
}
3 changes: 1 addition & 2 deletions src/settings.ts → src/settings/custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
*/

// Shorthand to access environment variables.
const env = import.meta.env as Record<string, string>
export default env
const env = import.meta.env as Record<string, string | undefined>

// The name of the current service.
export const SERVICE_NAME = env.VITE_SERVICE_NAME ?? "REPLACE_ME"
Expand Down
5 changes: 5 additions & 0 deletions src/settings/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Shorthand to access environment variables.
export default import.meta.env as Record<string, string>

export * from "./custom"
export * from "./vite"
Loading
Loading