-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathserver.ts
More file actions
288 lines (239 loc) · 9.31 KB
/
server.ts
File metadata and controls
288 lines (239 loc) · 9.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
/**
* Purpose:
* Main application entry point.
*
* Responsibilities:
* - Create and configure the Express server
* - Mount API routes
* - Integrate Vite for SSR in development
* - Serve static assets in production
*
* Design notes:
* - Single server for API + SSR
* - Fully stateless backend
* - No Express sessions
*
* Related docs:
* - https://expressjs.com/
* - https://vitejs.dev/guide/ssr
*/
import { AsyncLocalStorage } from "node:async_hooks";
import fs from "node:fs";
import express, { type ErrorRequestHandler, type Express } from "express";
import { rateLimit } from "express-rate-limit";
import helmet from "helmet";
import { createServer as createViteServer } from "vite";
/**
* Patch globalThis.fetch to support relative URLs during SSR.
*
* In Node.js:
* - fetch("/api") throws "Absolute URL required"
* - We need to resolve relative URLs against the request URL
*
* Solution:
* - Create a storage that holds the base URL for the current request
* - Patch fetch to resolve relative URLs against this base URL
*/
const fetchBaseStorage = new AsyncLocalStorage<{
base: string;
cookie?: string;
}>();
const nodeFetch = globalThis.fetch;
globalThis.fetch = (resource, init) => {
const store = fetchBaseStorage.getStore();
// 1. Resolve relative URLs against the request base URL
const url = store?.base ? new URL(resource.toString(), store.base) : resource;
// 2. Forward cookies for internal API calls (relative paths) during SSR
// Security: Only forward for relative paths starting with "/" but not "//"
// to avoid leaking cookies to external domains via protocol-relative URLs.
const isInternal =
typeof resource === "string" &&
resource.startsWith("/") &&
!resource.startsWith("//");
if (isInternal && store?.cookie) {
const headers = new Headers(init?.headers);
if (!headers.has("cookie")) {
headers.set("cookie", store.cookie);
}
return nodeFetch(url, { ...init, headers });
}
return nodeFetch(url, init);
};
/* ************************************************************************ */
/* Startup */
/* ************************************************************************ */
const isProduction = process.env.NODE_ENV === "production";
const port = +(process.env.APP_PORT ?? 5173);
const indexHtml = readIndexHtml();
// Server creation is async because it may initialize Vite in dev mode
createServer().then((server) => {
server.listen(port, () => {
console.info(`Listening on http://localhost:${port}`);
});
});
/* ************************************************************************ */
/* Server creation */
/* ************************************************************************ */
export async function createServer() {
const app = express();
/* ********************************************************************** */
/* Helmet */
/* ********************************************************************** */
// SECURITY:
// Sets HTTP response headers such as Content-Security-Policy and
// Strict-Transport-Security. See https://helmetjs.github.io/ for details.
//
// Content-Security-Policy is enabled only in production.
// In development it is disabled because Vite’s HMR relies on
// WebSocket connections and dynamic module evaluation, which
// are blocked by Helmet’s default CSP.
app.use(
helmet({
contentSecurityPolicy: isProduction,
}),
);
/* ********************************************************************** */
/* Rate limiting */
/* ********************************************************************** */
// SECURITY:
// Basic rate limiting to mitigate brute-force and abuse.
// This is intentionally simple and should be tuned per deployment.
if (isProduction) {
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 100, // max 100 requests per window
});
app.use(limiter);
}
/* ********************************************************************** */
/* API routes */
/* ********************************************************************** */
// All API routes are mounted here.
// They are isolated, stateless, and independently testable.
app.use((await import("./src/express/routes")).default);
/* ********************************************************************** */
/* Frontend / SSR configuration */
/* ********************************************************************** */
const maybeVite = await configure(app);
/* ****************************************************************** */
/* Load HTML template and SSR renderer */
/* ****************************************************************** */
const getTemplateAndRender = async (url: string) => {
// Production mode:
// SSR bundle is prebuilt and loaded from dist/
if (maybeVite == null) {
// NOTE:
// This file does not exist before the build step.
// @ts-expect-error - runtime-only import
const { render } = await import("./dist/server/entry-server");
return { template: indexHtml, render };
}
// Development mode:
// Vite handles on-the-fly module loading and HMR
const vite = maybeVite;
// 1. Apply Vite HTML transforms (HMR client, plugin hooks, etc.)
const template = await vite.transformIndexHtml(url, indexHtml);
// 2. Load the SSR entry module via Vite
const { render } = await vite.ssrLoadModule("/src/entry-server");
return { template, render };
};
// Catch-all handler for SSR
app.use(/(.*)/, async (req, res, next) => {
const url = req.originalUrl;
const base = `http://localhost:${port}${url}`;
const cookie = req.headers.cookie;
fetchBaseStorage.run({ base, cookie }, async () => {
try {
// Prevent caching of the HTML page
// SSR is auth-aware and must not be cached
res.set("Cache-Control", "private, no-store");
/* **************************************************************** */
/* Render application */
/* **************************************************************** */
const { template, render } = await getTemplateAndRender(url);
// The render function is responsible for:
// - Rendering the React app
// - Injecting HTML into the template
// - Sending the response
await render(template, req, res);
} catch (err) {
// DEV EXPERIENCE:
// Let Vite rewrite stack traces so they map to source files.
if (err instanceof Error) maybeVite?.ssrFixStacktrace(err);
next(err);
}
});
});
/* ********************************************************************** */
/* Error handling */
/* ********************************************************************** */
/*
Error logging middleware:
Logs errors for debugging, then passes them to the error response handler.
*/
const logErrors: ErrorRequestHandler = (err, req, _res, next) => {
console.error(err);
console.error("on req:", req.method, req.path);
next(err);
};
/*
Final error handler:
Sends a structured JSON response instead of Express's default HTML page.
Stack traces are hidden in production to avoid leaking implementation details.
*/
const sendErrors: ErrorRequestHandler = (err, _req, res, _next) => {
const status = err.status ?? err.statusCode ?? 500;
res.status(status).json({
message: err.message ?? "Internal Server Error",
...(isProduction ? {} : { stack: err.stack }),
});
};
app.use(logErrors);
app.use(sendErrors);
return app;
}
/* ************************************************************************ */
/* Helper utils */
/* ************************************************************************ */
/**
* Reads the HTML template depending on the environment.
*
* - Development: unbuilt index.html
* - Production: generated dist/client/index.html
*/
function readIndexHtml() {
return fs.readFileSync(
isProduction ? "dist/client/index.html" : "index.html",
"utf-8",
);
}
/**
* Configure frontend serving depending on environment.
*
* - Production:
* - Enable compression
* - Serve static assets
*
* - Development:
* - Create a Vite dev server in middleware mode
* - Let Express control routing
*/
async function configure(app: Express) {
if (isProduction) {
const compression = (await import("compression")).default;
app.use(compression());
app.use(express.static("./dist/client", { extensions: [] }));
} else {
// Create Vite server in middleware mode.
// Express remains the main HTTP server.
const vite = await createViteServer({
server: { middlewareMode: true },
appType: "custom",
});
// NOTE:
// vite.middlewares remains stable across restarts,
// even if Vite internally reloads plugins or config.
app.use(vite.middlewares);
return vite;
}
}