Skip to content

Commit d04dd59

Browse files
authored
feat: otp deploy (#70)
1 parent 2624786 commit d04dd59

File tree

8 files changed

+693
-59
lines changed

8 files changed

+693
-59
lines changed

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,22 +44,27 @@
4444
"@mui/material": "^5.11.12",
4545
"@mui/x-date-pickers": "^7.7.1",
4646
"@reduxjs/toolkit": "^2.0.1",
47+
"compression": "^1.7.5",
4748
"dayjs": "^1.11.11",
49+
"express": "^4.21.2",
4850
"formik": "^2.2.9",
4951
"js-cookie": "^3.0.5",
52+
"memory-cache": "^0.2.0",
5053
"qs": "^6.11.2",
5154
"react": "^18.2.0",
5255
"react-dom": "^18.2.0",
5356
"react-redux": "^9.1.0",
5457
"react-router-dom": "^6.23.1",
5558
"serve": "^14.2.3",
59+
"sirv": "^3.0.0",
5660
"yup": "^1.1.1"
5761
},
5862
"devDependencies": {
5963
"@testing-library/dom": "^9.3.4",
6064
"@testing-library/jest-dom": "^6.2.0",
6165
"@testing-library/react": "^14.1.2",
6266
"@testing-library/user-event": "^14.5.2",
67+
"@types/express": "^5.0.0",
6368
"@types/js-cookie": "^3.0.3",
6469
"@types/node": "^20.14.2",
6570
"@types/qs": "^6.9.7",
@@ -84,6 +89,7 @@
8489
"@testing-library/jest-dom": "^6.2.0",
8590
"@testing-library/react": "^14.1.2",
8691
"@testing-library/user-event": "^14.5.2",
92+
"@types/express": "^5.0.0",
8793
"@types/jest": "^29.5.12",
8894
"@types/js-cookie": "^3.0.3",
8995
"@types/node": "^20.14.2",

src/components/App.tsx

Lines changed: 48 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
import { CssBaseline, ThemeProvider } from "@mui/material"
22
import { type ThemeProviderProps } from "@mui/material/styles/ThemeProvider"
3-
import { useCallback, type FC, type ReactNode } from "react"
3+
import { type FC, type ReactNode } from "react"
44
import { Provider, type ProviderProps } from "react-redux"
55
import { BrowserRouter, Routes as RouterRoutes } from "react-router-dom"
6+
import { StaticRouter } from "react-router-dom/server"
67
import { type Action } from "redux"
78

89
import "./App.css"
9-
import { InactiveDialog, ScreenTimeDialog } from "../features"
10-
import { useCountdown, useEventListener, useLocation } from "../hooks"
10+
import { useLocation } from "../hooks"
11+
import { SSR } from "../settings"
12+
// import { InactiveDialog, ScreenTimeDialog } from "../features"
13+
// import { useCountdown, useEventListener } from "../hooks"
1114
// import "../scripts"
1215
// import {
1316
// configureFreshworksWidget,
1417
// toggleOneTrustInfoDisplay,
1518
// } from "../utils/window"
1619

1720
export interface AppProps<A extends Action = Action, S = unknown> {
21+
path?: string
1822
theme: ThemeProviderProps["theme"]
1923
store: ProviderProps<A, S>["store"]
2024
routes: ReactNode
@@ -26,53 +30,54 @@ export interface AppProps<A extends Action = Action, S = unknown> {
2630
maxTotalSeconds?: number
2731
}
2832

29-
const Routes: FC<
30-
Pick<
31-
AppProps,
32-
"routes" | "header" | "footer" | "headerExcludePaths" | "footerExcludePaths"
33-
>
34-
> = ({
33+
type BaseRoutesProps = Pick<
34+
AppProps,
35+
"routes" | "header" | "footer" | "headerExcludePaths" | "footerExcludePaths"
36+
>
37+
38+
const Routes: FC<BaseRoutesProps & { path: string }> = ({
39+
path,
3540
routes,
3641
header = <></>, // TODO: "header = <Header />"
3742
footer = <></>, // TODO: "footer = <Footer />"
3843
headerExcludePaths = [],
3944
footerExcludePaths = [],
40-
}) => {
45+
}) => (
46+
<>
47+
{!headerExcludePaths.includes(path) && header}
48+
<RouterRoutes>{routes}</RouterRoutes>
49+
{!footerExcludePaths.includes(path) && footer}
50+
</>
51+
)
52+
53+
const BrowserRoutes: FC<BaseRoutesProps> = props => {
4154
const { pathname } = useLocation()
4255

43-
return (
44-
<>
45-
{!headerExcludePaths.includes(pathname) && header}
46-
<RouterRoutes>{routes}</RouterRoutes>
47-
{!footerExcludePaths.includes(pathname) && footer}
48-
</>
49-
)
56+
return <Routes path={pathname} {...props} />
5057
}
5158

5259
const App = <A extends Action = Action, S = unknown>({
60+
path,
5361
theme,
5462
store,
55-
routes,
56-
header,
57-
footer,
58-
headerExcludePaths = [],
59-
footerExcludePaths = [],
6063
maxIdleSeconds = 60 * 60,
6164
maxTotalSeconds = 60 * 60,
65+
...routesProps
6266
}: AppProps<A, S>): JSX.Element => {
63-
const root = document.getElementById("root") as HTMLElement
67+
// TODO: cannot use document during SSR
68+
// const root = document.getElementById("root") as HTMLElement
6469

65-
const [idleSeconds, setIdleSeconds] = useCountdown(maxIdleSeconds)
66-
const [totalSeconds, setTotalSeconds] = useCountdown(maxTotalSeconds)
67-
const resetIdleSeconds = useCallback(() => {
68-
setIdleSeconds(maxIdleSeconds)
69-
}, [setIdleSeconds, maxIdleSeconds])
70+
// const [idleSeconds, setIdleSeconds] = useCountdown(maxIdleSeconds)
71+
// const [totalSeconds, setTotalSeconds] = useCountdown(maxTotalSeconds)
72+
// const resetIdleSeconds = useCallback(() => {
73+
// setIdleSeconds(maxIdleSeconds)
74+
// }, [setIdleSeconds, maxIdleSeconds])
7075

71-
const isIdle = idleSeconds === 0
72-
const tooMuchScreenTime = totalSeconds === 0
76+
// const isIdle = idleSeconds === 0
77+
// const tooMuchScreenTime = totalSeconds === 0
7378

74-
useEventListener(root, "mousemove", resetIdleSeconds)
75-
useEventListener(root, "keypress", resetIdleSeconds)
79+
// useEventListener(root, "mousemove", resetIdleSeconds)
80+
// useEventListener(root, "keypress", resetIdleSeconds)
7681

7782
// React.useEffect(() => {
7883
// configureFreshworksWidget("hide")
@@ -86,22 +91,22 @@ const App = <A extends Action = Action, S = unknown>({
8691
<ThemeProvider theme={theme}>
8792
<CssBaseline />
8893
<Provider store={store}>
89-
<InactiveDialog open={isIdle} onClose={resetIdleSeconds} />
94+
{/* <InactiveDialog open={isIdle} onClose={resetIdleSeconds} />
9095
<ScreenTimeDialog
9196
open={!isIdle && tooMuchScreenTime}
9297
onClose={() => {
9398
setTotalSeconds(maxTotalSeconds)
9499
}}
95-
/>
96-
<BrowserRouter>
97-
<Routes
98-
routes={routes}
99-
header={header}
100-
footer={footer}
101-
headerExcludePaths={headerExcludePaths}
102-
footerExcludePaths={footerExcludePaths}
103-
/>
104-
</BrowserRouter>
100+
/> */}
101+
{SSR ? (
102+
<StaticRouter location={path as string}>
103+
<Routes path={path as string} {...routesProps} />
104+
</StaticRouter>
105+
) : (
106+
<BrowserRouter>
107+
<BrowserRoutes {...routesProps} />
108+
</BrowserRouter>
109+
)}
105110
</Provider>
106111
</ThemeProvider>
107112
)

src/server.js

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* © Ocado Group
3+
* Created on 13/12/2024 at 12:15:05(+00:00).
4+
*
5+
* A server for an app in a live environment.
6+
* Based off: https://github.com/bluwy/create-vite-extra/blob/master/template-ssr-react-ts/server.js
7+
*/
8+
9+
import fs from "node:fs/promises"
10+
import express from "express"
11+
import { Cache } from "memory-cache"
12+
13+
export default class Server {
14+
constructor(
15+
/** @type {Partial<{ mode: "development" | "staging" | "production"; port: number; base: string }>} */
16+
{ mode, port, base } = {},
17+
) {
18+
/** @type {"development" | "staging" | "production"} */
19+
this.mode = mode || process.env.MODE || "development"
20+
/** @type {number} */
21+
this.port = port || (process.env.PORT ? Number(process.env.PORT) : 5173)
22+
/** @type {string} */
23+
this.base = base || process.env.BASE || "/"
24+
25+
/** @type {boolean} */
26+
this.envIsProduction = process.env.NODE_ENV === "production"
27+
/** @type {string} */
28+
this.templateHtml = ""
29+
/** @type {string} */
30+
this.hostname = this.envIsProduction ? "0.0.0.0" : "127.0.0.1"
31+
32+
/** @type {import('express').Express} */
33+
this.app = express()
34+
/** @type {import('vite').ViteDevServer | undefined} */
35+
this.vite = undefined
36+
/** @type {import('memory-cache').Cache<string, any>} */
37+
this.cache = new Cache()
38+
39+
/** @type {string} */
40+
this.healthCheckCacheKey = "health-check"
41+
/** @type {number} */
42+
this.healthCheckCacheTimeout = 30000
43+
/** @type {Record<"healthy" | "startingUp" | "shuttingDown" | "unhealthy" | "unknown", number>} */
44+
this.healthCheckStatusCodes = {
45+
// The app is running normally.
46+
healthy: 200,
47+
// The app is performing app-specific initialisation which must
48+
// complete before it will serve normal application requests
49+
// (perhaps the app is warming a cache or something similar). You
50+
// only need to use this status if your app will be in a start-up
51+
// mode for a prolonged period of time.
52+
startingUp: 503,
53+
// The app is shutting down. As with startingUp, you only need to
54+
// use this status if your app takes a prolonged amount of time
55+
// to shutdown, perhaps because it waits for a long-running
56+
// process to complete before shutting down.
57+
shuttingDown: 503,
58+
// The app is not running normally.
59+
unhealthy: 503,
60+
// The app is not able to report its own state.
61+
unknown: 503,
62+
}
63+
}
64+
65+
/** @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" }> }} */
66+
getHealthCheck(request) {
67+
return {
68+
healthStatus: "healthy",
69+
additionalInfo: "All healthy.",
70+
}
71+
}
72+
73+
/** @type {(request: import('express').Request, response: import('express').Response) => void} */
74+
handleHealthCheck(request, response) {
75+
/** @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" }> }} */
76+
let value = this.cache.get(this.healthCheckCacheKey)
77+
if (value === null) {
78+
const healthCheck = this.getHealthCheck(request)
79+
80+
if (healthCheck.healthStatus !== "healthy") {
81+
console.warn(`health check: ${JSON.stringify(healthCheck)}`)
82+
}
83+
84+
value = {
85+
appId: process.env.APP_ID || "REPLACE_ME",
86+
healthStatus: healthCheck.healthStatus,
87+
lastCheckedTimestamp: new Date().toISOString(),
88+
additionalInformation: healthCheck.additionalInfo,
89+
startupTimestamp: new Date().toISOString(),
90+
appVersion: process.env.APP_VERSION || "REPLACE_ME",
91+
details: healthCheck.details || [],
92+
}
93+
94+
this.cache.put(
95+
this.healthCheckCacheKey,
96+
value,
97+
this.healthCheckCacheTimeout,
98+
)
99+
}
100+
101+
response.status(this.healthCheckStatusCodes[value.healthStatus]).json(value)
102+
}
103+
104+
/** @type {(request: import('express').Request, response: import('express').Response) => Promise<void>} */
105+
async handleServeHtml(request, response) {
106+
try {
107+
const path = request.originalUrl.replace(this.base, "")
108+
109+
/** @type {string} */
110+
let template
111+
/** @type {(path: string) => Promise<{ head?: string; html?: string }>} */
112+
let render
113+
if (this.envIsProduction) {
114+
render = (await import("../../../dist/server/entry-server.js")).render
115+
116+
// Use cached template.
117+
template = this.templateHtml
118+
} else {
119+
render = (await this.vite.ssrLoadModule("/src/entry-server.tsx")).render
120+
121+
// Always read fresh template.
122+
template = await fs.readFile("./index.html", "utf-8")
123+
template = await this.vite.transformIndexHtml(path, template)
124+
}
125+
126+
const rendered = await render(path)
127+
128+
const html = template
129+
.replace(`<!--app-head-->`, rendered.head ?? "")
130+
.replace(`<!--app-html-->`, rendered.html ?? "")
131+
132+
response.status(200).set({ "Content-Type": "text/html" }).send(html)
133+
} catch (error) {
134+
this.vite?.ssrFixStacktrace(error)
135+
console.error(error.stack)
136+
response.status(500).end(this.envIsProduction ? undefined : error.stack)
137+
}
138+
}
139+
140+
async run() {
141+
this.app.get("/health-check", (request, response) => {
142+
this.handleHealthCheck(request, response)
143+
})
144+
145+
if (this.envIsProduction) {
146+
const compression = (await import("compression")).default
147+
const sirv = (await import("sirv")).default
148+
149+
this.templateHtml = await fs.readFile("./dist/client/index.html", "utf-8")
150+
151+
this.app.use(compression())
152+
this.app.use(this.base, sirv("./dist/client", { extensions: [] }))
153+
} else {
154+
const { createServer } = await import("vite")
155+
156+
this.vite = await createServer({
157+
server: { middlewareMode: true },
158+
appType: "custom",
159+
base: this.base,
160+
mode: this.mode,
161+
})
162+
163+
this.app.use(this.vite.middlewares)
164+
}
165+
166+
this.app.get("*", async (request, response) => {
167+
await this.handleServeHtml(request, response)
168+
})
169+
170+
this.app.listen(this.port, this.hostname, () => {
171+
let startMessage =
172+
"Server started.\n" +
173+
`url: http://${this.hostname}:${this.port}\n` +
174+
`environment: ${process.env.NODE_ENV}\n`
175+
176+
if (!this.envIsProduction) startMessage += `mode: ${this.mode}\n`
177+
178+
console.log(startMessage)
179+
})
180+
}
181+
}

src/settings.ts renamed to src/settings/custom.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
*/
77

88
// Shorthand to access environment variables.
9-
const env = import.meta.env as Record<string, string>
10-
export default env
9+
const env = import.meta.env as Record<string, string | undefined>
1110

1211
// The name of the current service.
1312
export const SERVICE_NAME = env.VITE_SERVICE_NAME ?? "REPLACE_ME"

src/settings/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Shorthand to access environment variables.
2+
export default import.meta.env as Record<string, string>
3+
4+
export * from "./custom"
5+
export * from "./vite"

0 commit comments

Comments
 (0)