diff --git a/.dockerignore b/.dockerignore
index 255d1ab8..081ee586 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -10,6 +10,5 @@ docker/volumes/
docs/
node_modules/
playwright-report/
-public/build/
test-results/
test/
diff --git a/.gitignore b/.gitignore
index dabf6788..8417fe27 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+.cache
.env
build/
coverage/
@@ -5,6 +6,5 @@ docker/volumes/
node_modules/
playwright-report/
playwright/.cache/
-public/build/
test-results/
test/e2e/.auth
diff --git a/.oxlintrc.json b/.oxlintrc.json
index c5833c47..10e6d97b 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -10,7 +10,6 @@
"docs/**",
"node_modules/**",
"playwright-report/**",
- "public/build/**",
"test/e2e/**",
"test-results/**",
"playwright.config.ts"
diff --git a/Dockerfile b/Dockerfile
index ea5613b1..ae6e7cfa 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -56,8 +56,8 @@ USER node
COPY --chown=node:node --from=production-deps /app/.npmrc ./.npmrc
COPY --chown=node:node --from=production-deps /app/node_modules ./node_modules
COPY --chown=node:node --from=build /app/node_modules/.prisma ./node_modules/.prisma
-COPY --chown=node:node --from=build /app/build ./build
-COPY --chown=node:node --from=build /app/public ./public
+COPY --chown=node:node --from=build /app/build/server ./build/server
+COPY --chown=node:node --from=build /app/build/client ./build/client
COPY --chown=node:node --from=build /app/prisma ./prisma
# Include the SAML IDP metadata in the image. Specify the file to use in the build arg
@@ -67,7 +67,7 @@ ENV SAML_IDP_METADATA_PATH=/app/config/idp-metadata.xml
COPY docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["/usr/bin/tini", "--", "docker-entrypoint.sh"]
-CMD ["node", "./build/server.js"]
+CMD ["node", "./build/server/index.js"]
HEALTHCHECK CMD curl --fail http://localhost:${PORT}/healthcheck || exit 1
EXPOSE ${PORT}
diff --git a/app/entry.server.tsx b/app/entry.server.tsx
index dfb612a9..bec3c39b 100644
--- a/app/entry.server.tsx
+++ b/app/entry.server.tsx
@@ -8,6 +8,15 @@ import { renderToPipeableStream } from 'react-dom/server';
import createEmotionServer from '@emotion/server/create-instance';
import { CacheProvider } from '@emotion/react';
import createEmotionCache from './createEmotionCache';
+import { createExpressApp } from 'remix-create-express-app';
+import * as crypto from 'crypto';
+import helmet from 'helmet';
+import cors from 'cors';
+import compression from 'compression';
+import express from 'express';
+import * as services from 'services';
+
+import type { Application, Request as ExpressRequest, Response as ExpressResponse } from 'express';
const ABORT_DELAY = 5000;
@@ -62,3 +71,67 @@ export default function handleRequest(
setTimeout(abort, ABORT_DELAY);
});
}
+
+export const app = await createExpressApp({
+ configure: (app: Application) => {
+ // setup additional express middleware here
+ const MODE = process.env.NODE_ENV;
+
+ app.use(compression());
+ app.use(cors());
+ app.use(
+ helmet({
+ contentSecurityPolicy: {
+ useDefaults: true,
+ directives: {
+ // Expect a nonce on scripts
+ scriptSrc: [
+ "'self'",
+ (_req, res) => `'nonce-${(res as ExpressResponse).locals.nonce}'`,
+ ],
+ // Allow live reload to work over a web socket in development
+ connectSrc: MODE === 'production' ? ["'self'"] : ["'self'", 'ws:'],
+ // Don't force https unless in production
+ upgradeInsecureRequests: MODE === 'production' ? [] : null,
+ },
+ },
+ })
+ );
+
+ // Remix fingerprints its assets so we can cache forever.
+ app.use('/build', express.static('build/client', { immutable: true, maxAge: '1y' }));
+
+ // Everything else (like favicon.ico) is cached for an hour. You may want to be
+ // more aggressive with this caching.
+ app.use(express.static('public', { maxAge: '1h' }));
+
+ app.use((_req, res, next) => {
+ res.locals.nonce = crypto.randomBytes(16).toString('hex');
+ next();
+ });
+ app.use((req, res, next) => {
+ // /clean-urls/ -> /clean-urls
+ if (req.path.endsWith('/') && req.path.length > 1) {
+ const query = req.url.slice(req.path.length);
+ const safepath = req.path.slice(0, -1).replace(/\/+/g, '/');
+ res.redirect(301, safepath + query);
+ return;
+ }
+ next();
+ });
+ },
+ // Pass the nonce we're setting in the CSP headers down to the Remix Loader/Action functions
+ getLoadContext: (_req: ExpressRequest, res: ExpressResponse) => ({ nonce: res.locals.nonce }),
+ createServer: (app) => {
+ const PORT = process.env.PORT || 8080;
+
+ const server = app.listen(PORT, () => {
+ // start the various background jobs we run (reconciler, expire records, etc)
+ services.init().then(() => {
+ logger.info(`✅ app ready: http://localhost:${PORT}`);
+ });
+ });
+
+ return server;
+ },
+});
diff --git a/app/root.tsx b/app/root.tsx
index c0ebb204..fac42cd7 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -1,14 +1,6 @@
import { ChakraProvider } from '@chakra-ui/react';
import { json } from '@remix-run/node';
-import {
- Links,
- LiveReload,
- Meta,
- Outlet,
- Scripts,
- ScrollRestoration,
- useLoaderData,
-} from '@remix-run/react';
+import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from '@remix-run/react';
import { getUser, getEffectiveUser, stopImpersonation } from './session.server';
@@ -70,7 +62,6 @@ function Document({ children }: { children: React.ReactNode }) {
{children}
-