Skip to content

Commit

Permalink
feat: otp deploy (#70)
Browse files Browse the repository at this point in the history
  • Loading branch information
SKairinos authored Dec 13, 2024
1 parent 2624786 commit d04dd59
Show file tree
Hide file tree
Showing 8 changed files with 693 additions and 59 deletions.
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

0 comments on commit d04dd59

Please sign in to comment.