@quark-fw/plugin-react 0.1.26 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # @quark-fw/plugin-react
2
+
3
+ React 19 SSR frontend for Quark apps: an express server with vite in dev and a prebuilt bundle in production, file-based routing over `src/frontend/Pages`, streaming render with the store cache inlined for hydration, per-page SEO meta, optional i18n with locale prefixes, Mantine providers, request ids and security headers on every response.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm i @quark-fw/plugin-react
9
+ ```
10
+
11
+ Peers: `@quark-fw/core`, `@quark-fw/store`, `react`, `react-dom`, `react-router-dom`, `vite`, `@vitejs/plugin-react`, `@mantine/core`, `@mantine/dates`, `@mantine/modals`, `@mantine/notifications`. The app runs the backend with `tsx` (`tsx watch src/index.ts` in dev, `NODE_ENV=production tsx src/index.ts` in prod).
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ // src/index.ts — react goes last: it mounts the routes other plugins registered
17
+ import "@quark-fw/plugin-trpc";
18
+ import "@quark-fw/plugin-react"; // provides quark.react; the server starts in onReady
19
+ import "./router/index.js";
20
+
21
+ quark.react.port.value = Number(process.env.PORT) || 3001;
22
+ quark.react.lang.value = "ru"; // <html lang> for apps without i18n
23
+ quark.react.favicon.value = IDLE_FAVICON; // path or data-URI; empty string = no <link rel="icon">
24
+
25
+ // src/build.ts — `npm run build` is `tsx src/build.ts`
26
+ import { buildApp } from "@quark-fw/plugin-react/src/build.js";
27
+ await buildApp();
28
+ ```
29
+
30
+ ```tsx
31
+ // src/frontend/Pages/Main/Page.tsx → "/"
32
+ import { createPageMeta } from "@quark-fw/plugin-react/src/seo/createPageMeta.js";
33
+ import type { UglyPageProps } from "@quark-fw/plugin-react/src/Router/Router";
34
+
35
+ function Page(_props: UglyPageProps) { /* ... */ }
36
+ Page.meta = createPageMeta({
37
+ title: async ({ store }) => (await store.tiny.auth.me.getPromise()) ? "Todo" : "Todo — sign in",
38
+ description: "...",
39
+ robots: "noindex",
40
+ });
41
+ export default Page;
42
+ ```
43
+
44
+ App contract (the vite root is `src/frontend`; the plugin owns `entry-client.tsx` / `entry-server.tsx` and discovers app files with `import.meta.glob`):
45
+
46
+ - `Pages/**/Page.tsx` — a page; the path is the route: `Main/Page.tsx` → `/`, `Projects/[id]/Page.tsx` → `/projects/:id`, `[...rest]` → `*`, lowercased. Optional `Layout.tsx` in a folder wraps everything below it (`UglyLayoutProps` with `children`), `LayoutReset.tsx` stops inheriting outer layouts. A page may export `meta` (`createPageMeta`): `title`, `description`, `robots`, `og`, `jsonLD`, `breadcrumbs`, `status`, `redirect`, `canonical` — each a value or a (possibly async) function of `{ store, params, locale, origin, query }`, resolved on the server before the stream starts and again on client navigation.
47
+ - `theme.ts` — exports `appTheme` (Mantine `createTheme`); imported as the virtual module `/theme.ts`.
48
+ - `i18n.ts` (optional) — exports `i18nConfig = defineI18nConfig({ locales, defaultLocale, urlPrefix? })`; dictionaries in `locales/<lang>/<namespace>.ts`, read with `useI18n()`. With a config, URLs carry `/<locale>` prefixes, a bare path 302-redirects to the locale from the `locale` cookie or `Accept-Language`, and the head gets an hreflang cluster. `urlPrefix: false` is honoured only with a single locale.
49
+ - `tsconfig.json`, `postcss.config.cjs` for Mantine, `types.d.ts` for CSS modules.
50
+ - `static/` at the app root (optional) is served as-is in dev and copied into `build/static` by the build.
51
+
52
+ How it works: `onReady` creates the express app, applies `requestContextMiddleware` (accepts an incoming `X-Request-Id` matching `[A-Za-z0-9._-]{1,128}` or generates a uuid, echoes it in the response, exposes it to the logger through AsyncLocalStorage and writes an access line per request — except `/assets/`), disables `x-powered-by`, adds `securityHeadersMiddleware` (`X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, and `Strict-Transport-Security` only when `req.secure`; no CSP because SSR inlines scripts), then the pre-middleware of other plugins (session, passport), their routes (`/api/trpc`, `/healthz`, ...), static files and finally the SSR middleware. Trailing slashes 301-redirect to the canonical form.
53
+
54
+ During render the store talks to tRPC through one of three transports, in priority order: `SSR_TRPC_URL` (HTTP to another process, cookie forwarded), the in-process router of `@quark-fw/plugin-trpc` (no HTTP, same `req`/`res` context), or a loopback HTTP call to its own port. Resolved cache entries are written into `window.cache` as the stream goes so the client hydrates without refetching. Changing `quark.react.port` restarts the listener; `onDestroy` closes the server and vite.
55
+
56
+ Production: `buildApp()` writes a client bundle with a manifest to `build/client`, an SSR bundle to `build/server/entry-server.js` and copies `static/`; the server refuses to start without them. Hashed `/assets` are served with `immutable, max-age=1y`.
57
+
58
+ Env: `PORT` (default 3000; the apps override it through `quark.react.port`), `NODE_ENV` (`production` switches from vite to the build), `TRUST_PROXY` (express `trust proxy`: a number is the count of trusted hops — `1` behind Coolify/Traefik —, `true` trusts every `X-Forwarded-*`, anything else is a list of addresses or subnets; without it `req.secure` is false behind TLS termination, so no HSTS and no secure cookies), `SSR_TRPC_URL` (external tRPC endpoint for SSR, overrides the in-process router), `PUBLIC_ORIGIN` (canonical `https://host` for canonical / og:url / hreflang / JSON-LD; otherwise taken from the request), `INJECT_HTML` (operator markup — counters, site verification — appended as-is to the end of `<head>`).
59
+
60
+ Part of the [Quark framework](https://www.npmjs.com/package/@quark-fw/core).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quark-fw/plugin-react",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "main": "src/index.ts",
5
5
  "types": "src/index.ts",
6
6
  "type": "module",
@@ -22,8 +22,8 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@quark-fw/atom": "0.1.26",
26
- "@quark-fw/plugin": "0.1.26",
25
+ "@quark-fw/atom": "0.1.27",
26
+ "@quark-fw/plugin": "0.1.27",
27
27
  "@trpc/client": "~11.18.0",
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "express": "^5.2.1",
@@ -36,8 +36,8 @@
36
36
  "@mantine/dates": "^9.0.0",
37
37
  "@mantine/modals": "^9.0.0",
38
38
  "@mantine/notifications": "^9.0.0",
39
- "@quark-fw/core": "0.1.26",
40
- "@quark-fw/store": "0.1.26",
39
+ "@quark-fw/core": "0.1.27",
40
+ "@quark-fw/store": "0.1.27",
41
41
  "@vitejs/plugin-react": "^5.1.0",
42
42
  "react": "^19.2.4",
43
43
  "react-dom": "^19.2.4",
@@ -1,3 +1,7 @@
1
+ // объявление виртуального модуля vite: import его дать не может, поэтому
2
+ // reference path — и правило про него выключено осознанно
3
+ // eslint-disable-next-line @typescript-eslint/triple-slash-reference
4
+ /// <reference path="./vite-virtual.d.ts" />
1
5
  import "vite/modulepreload-polyfill";
2
6
  // ВСЕ стили Mantine — только layered-варианты. Смешение layered ядра с
3
7
  // не-layered пакетами меняет приоритеты правил, и в прод-сборке (где CSS
@@ -7,10 +7,16 @@ import { errorMessage, setErrorHandler } from "@quark-fw/store/src/errors.js";
7
7
  if (typeof window !== "undefined") {
8
8
  setErrorHandler((error, context) => {
9
9
  console.error(context ? `[${context}]` : "[quark]", error);
10
+ // requestId приходит в data ошибки tRPC (errorFormatter плагина):
11
+ // по нему строки этого запроса находятся в логах сервера
12
+ const requestId = (error as { data?: { requestId?: string } })?.data
13
+ ?.requestId;
10
14
  notifications.show({
11
15
  color: "red",
12
16
  title: context ?? "Ошибка",
13
- message: errorMessage(error),
17
+ message: requestId
18
+ ? `${errorMessage(error)} · id ${requestId.slice(0, 8)}`
19
+ : errorMessage(error),
14
20
  });
15
21
  });
16
22
  }
@@ -0,0 +1,5 @@
1
+ // Виртуальные модули Vite: резолвятся плагином vite:modulepreload-polyfill,
2
+ // на диске их нет, и TypeScript 7 (проверяет side-effect импорты) без
3
+ // объявления не находит модуль. Файл подключается reference path из
4
+ // App.tsx — так он попадает и в программы приложений.
5
+ declare module "vite/modulepreload-polyfill";
package/src/index.ts CHANGED
@@ -7,7 +7,13 @@ import getRenderMiddleware from "./ssr/getRenderMiddleware.js";
7
7
  import { getVite } from "./utils/vite.js";
8
8
  import { appFrontendRoot, buildPaths } from "./utils/viteConfig.js";
9
9
  import { httpRouter, preRouter } from "@quark-fw/plugin/http.js";
10
+ import { requestContextMiddleware } from "./requestContextMiddleware.js";
11
+ import { securityHeadersMiddleware } from "./securityHeadersMiddleware.js";
12
+ import { parseTrustProxy } from "./utils/parseTrustProxy.js";
10
13
  import {} from "@quark-fw/plugin-react/src/global.js";
14
+ import { log as rootLog } from "@quark-fw/core/src/log.js";
15
+
16
+ const log = rootLog.child({ scope: "react" });
11
17
 
12
18
  const isProd = process.env.NODE_ENV === "production";
13
19
 
@@ -31,11 +37,15 @@ await quark.registerPlugin({
31
37
 
32
38
  // за обратным прокси (TLS-терминация) нужно доверять X-Forwarded-*,
33
39
  // иначе req.secure всегда false и secure-cookie не выставляются.
34
- // Включается явно: TRUST_PROXY=1 (или список адресов).
35
- if (process.env.TRUST_PROXY) {
36
- app.set("trust proxy", process.env.TRUST_PROXY);
37
- }
40
+ // Включается явно: TRUST_PROXY=1 (число хопов) или список адресов.
41
+ const trustProxy = parseTrustProxy(process.env.TRUST_PROXY);
42
+ if (trustProxy !== undefined) app.set("trust proxy", trustProxy);
38
43
 
44
+ // request id и контекст запроса — раньше всего остального, чтобы
45
+ // каждая строка лога любого middleware была привязана к запросу
46
+ app.use(requestContextMiddleware);
47
+ app.disable("x-powered-by");
48
+ app.use(securityHeadersMiddleware);
39
49
  // сквозные middleware (сессии, passport) — раньше всех обработчиков
40
50
  app.use(preRouter);
41
51
  // маршруты плагинов (например /api/trpc) — до статики и SSR
@@ -76,9 +86,10 @@ await quark.registerPlugin({
76
86
 
77
87
  const listen = (port: number) => {
78
88
  server = app.listen(port, () => {
79
- console.log(
80
- `[react] ${isProd ? "production" : "dev"} http://localhost:${port}`,
81
- );
89
+ log.info("listening", {
90
+ mode: isProd ? "production" : "dev",
91
+ url: `http://localhost:${port}`,
92
+ });
82
93
  });
83
94
  };
84
95
 
@@ -99,8 +110,16 @@ await quark.registerPlugin({
99
110
  async onDestroy() {
100
111
  unsubscribePort?.();
101
112
  unsubscribePort = undefined;
102
- server?.close();
113
+ const current = server;
103
114
  server = undefined;
115
+ if (current) {
116
+ // close() ждёт только активные запросы, а keep-alive соединения
117
+ // без запроса держали бы процесс до таймаута shutdown в core
118
+ await new Promise<void>((resolve) => {
119
+ current.close(() => resolve());
120
+ current.closeIdleConnections();
121
+ });
122
+ }
104
123
  await vite?.close();
105
124
  vite = undefined;
106
125
  },
@@ -0,0 +1,52 @@
1
+ import { afterAll, beforeAll, expect, test } from "vitest";
2
+ import express from "express";
3
+ import type { Server } from "node:http";
4
+ import type { AddressInfo } from "node:net";
5
+ import { getRequestContext } from "@quark-fw/core/src/requestContext.js";
6
+ import { requestContextMiddleware } from "./requestContextMiddleware.js";
7
+
8
+ let server: Server;
9
+ let base: string;
10
+
11
+ beforeAll(async () => {
12
+ const app = express();
13
+ app.use(requestContextMiddleware);
14
+ // контекст виден и после await — так его читают процедуры и логгер
15
+ app.get("/ctx", async (_req, res) => {
16
+ await new Promise((r) => setTimeout(r, 1));
17
+ res.json({ requestId: getRequestContext()?.requestId ?? null });
18
+ });
19
+ await new Promise<void>((resolve) => {
20
+ server = app.listen(0, () => resolve());
21
+ });
22
+ base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
23
+ });
24
+
25
+ afterAll(() => {
26
+ server.close();
27
+ });
28
+
29
+ test("без заголовка: генерируется uuid, он же в ответе и в контексте", async () => {
30
+ const r = await fetch(`${base}/ctx`);
31
+ const header = r.headers.get("x-request-id");
32
+ expect(header).toMatch(/^[0-9a-f-]{36}$/);
33
+ expect(await r.json()).toEqual({ requestId: header });
34
+ });
35
+
36
+ test("безопасный входящий X-Request-Id сохраняется", async () => {
37
+ const r = await fetch(`${base}/ctx`, {
38
+ headers: { "x-request-id": "proxy-42.a_b" },
39
+ });
40
+ expect(r.headers.get("x-request-id")).toBe("proxy-42.a_b");
41
+ expect(await r.json()).toEqual({ requestId: "proxy-42.a_b" });
42
+ });
43
+
44
+ test("входящий id с посторонними символами или длиннее 128 заменяется", async () => {
45
+ for (const bad of ["a b", "<script>", "a".repeat(129)]) {
46
+ const r = await fetch(`${base}/ctx`, {
47
+ headers: { "x-request-id": bad },
48
+ });
49
+ expect(r.headers.get("x-request-id")).not.toBe(bad);
50
+ expect(r.headers.get("x-request-id")).toMatch(/^[0-9a-f-]{36}$/);
51
+ }
52
+ });
@@ -0,0 +1,41 @@
1
+ import crypto from "node:crypto";
2
+ import type { RequestHandler } from "express";
3
+ import { runWithRequestContext } from "@quark-fw/core/src/requestContext.js";
4
+ import { log as rootLog } from "@quark-fw/core/src/log.js";
5
+
6
+ const log = rootLog.child({ scope: "http" });
7
+
8
+ const HEADER = "x-request-id";
9
+ // id от прокси попадает в логи и в ответ — принимаем только безопасную форму
10
+ const SAFE_ID = /^[A-Za-z0-9._-]{1,128}$/;
11
+
12
+ /**
13
+ * Открывает контекст запроса: request id (входящий X-Request-Id от прокси
14
+ * или новый uuid) доступен логгеру через AsyncLocalStorage во всём, что
15
+ * выполняется внутри запроса — middleware, процедурах tRPC, SSR. Тот же id
16
+ * возвращается заголовком ответа, чтобы по жалобе пользователя найти строки.
17
+ *
18
+ * По завершении пишется строка доступа: метод, путь, статус, время.
19
+ * Хешированные ассеты не пишутся — это шум без пользы.
20
+ */
21
+ export const requestContextMiddleware: RequestHandler = (req, res, next) => {
22
+ const incoming = req.headers[HEADER];
23
+ const requestId =
24
+ typeof incoming === "string" && SAFE_ID.test(incoming)
25
+ ? incoming
26
+ : crypto.randomUUID();
27
+ res.setHeader(HEADER, requestId);
28
+
29
+ const started = performance.now();
30
+ res.on("finish", () => {
31
+ if (req.path.startsWith("/assets/")) return;
32
+ log.info("request", {
33
+ method: req.method,
34
+ path: req.originalUrl,
35
+ status: res.statusCode,
36
+ ms: Math.round(performance.now() - started),
37
+ });
38
+ });
39
+
40
+ runWithRequestContext({ requestId }, next);
41
+ };
@@ -0,0 +1,45 @@
1
+ import { afterAll, beforeAll, expect, test } from "vitest";
2
+ import express from "express";
3
+ import type { Server } from "node:http";
4
+ import type { AddressInfo } from "node:net";
5
+ import { securityHeadersMiddleware } from "./securityHeadersMiddleware.js";
6
+
7
+ let server: Server;
8
+ let base: string;
9
+
10
+ beforeAll(async () => {
11
+ const app = express();
12
+ // как в проде за Traefik: один доверенный хоп, протокол из заголовка
13
+ app.set("trust proxy", 1);
14
+ app.disable("x-powered-by");
15
+ app.use(securityHeadersMiddleware);
16
+ app.get("/", (_req, res) => res.send("ok"));
17
+ await new Promise<void>((resolve) => {
18
+ server = app.listen(0, () => resolve());
19
+ });
20
+ base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
21
+ });
22
+
23
+ afterAll(() => {
24
+ server.close();
25
+ });
26
+
27
+ test("http: базовые заголовки есть, HSTS и X-Powered-By нет", async () => {
28
+ const r = await fetch(base + "/");
29
+ expect(r.headers.get("x-content-type-options")).toBe("nosniff");
30
+ expect(r.headers.get("x-frame-options")).toBe("SAMEORIGIN");
31
+ expect(r.headers.get("referrer-policy")).toBe(
32
+ "strict-origin-when-cross-origin",
33
+ );
34
+ expect(r.headers.get("strict-transport-security")).toBeNull();
35
+ expect(r.headers.get("x-powered-by")).toBeNull();
36
+ });
37
+
38
+ test("https за доверенным прокси: добавляется HSTS", async () => {
39
+ const r = await fetch(base + "/", {
40
+ headers: { "x-forwarded-proto": "https" },
41
+ });
42
+ expect(r.headers.get("strict-transport-security")).toBe(
43
+ "max-age=15552000; includeSubDomains",
44
+ );
45
+ });
@@ -0,0 +1,26 @@
1
+ import type { RequestHandler } from "express";
2
+
3
+ /**
4
+ * Базовые заголовки безопасности. Без helmet: нужных заголовков четыре, а
5
+ * зависимость тянула бы дефолты, которые здесь не подходят (CSP, COOP).
6
+ *
7
+ * HSTS ставится только на https-ответ: req.secure за прокси работает через
8
+ * TRUST_PROXY, а на голом http заголовок браузеры игнорируют и он лишь
9
+ * мешает локальной отладке.
10
+ *
11
+ * CSP намеренно нет: SSR пишет инлайн-скрипты (window.cache в стриме,
12
+ * ColorSchemeScript Mantine), для неё нужен per-request nonce через стрим и
13
+ * renderToPipeableStream — отдельная задача.
14
+ */
15
+ export const securityHeadersMiddleware: RequestHandler = (req, res, next) => {
16
+ res.setHeader("X-Content-Type-Options", "nosniff");
17
+ res.setHeader("X-Frame-Options", "SAMEORIGIN");
18
+ res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
19
+ if (req.secure) {
20
+ res.setHeader(
21
+ "Strict-Transport-Security",
22
+ "max-age=15552000; includeSubDomains",
23
+ );
24
+ }
25
+ next();
26
+ };
@@ -1,6 +1,10 @@
1
1
  import { createTRPCClient, httpLink, unstable_localLink } from "@trpc/client";
2
2
  import type { Request, Response } from "express";
3
3
  import { jsonSafeFetch, type TRPCClient } from "@quark-fw/store";
4
+ import { getRequestContext } from "@quark-fw/core/src/requestContext.js";
5
+ import { log as rootLog } from "@quark-fw/core/src/log.js";
6
+
7
+ const log = rootLog.child({ scope: "react" });
4
8
 
5
9
  // Тип роутера берём из сигнатуры link'а: plugin-react не зависит от
6
10
  // @trpc/server и не должен тянуть его ради одного типа
@@ -47,6 +51,9 @@ export function createRequestTrpcClient({
47
51
  url,
48
52
  }: CreateRequestTrpcClientArgs): TRPCClient {
49
53
  const cookie = req.headers.cookie;
54
+ // тот же request id, что у SSR-запроса: по HTTP он уходит заголовком,
55
+ // и логи API-процесса связываются с логами рендера
56
+ const requestId = getRequestContext()?.requestId;
50
57
 
51
58
  if (!url && router && createContext) {
52
59
  return createTRPCClient({
@@ -58,7 +65,7 @@ export function createRequestTrpcClient({
58
65
  // только то, что по HTTP было бы 500
59
66
  onError: ({ error, path }) => {
60
67
  if (error.code === "INTERNAL_SERVER_ERROR") {
61
- console.error(`[react] ssr tRPC ${path}:`, error);
68
+ log.error("ssr tRPC failed", { path, err: error });
62
69
  }
63
70
  },
64
71
  }),
@@ -70,7 +77,10 @@ export function createRequestTrpcClient({
70
77
  links: [
71
78
  httpLink({
72
79
  url: url ?? `http://127.0.0.1:${port}/api/trpc`,
73
- headers: cookie ? { cookie } : {},
80
+ headers: {
81
+ ...(cookie ? { cookie } : {}),
82
+ ...(requestId ? { "x-request-id": requestId } : {}),
83
+ },
74
84
  fetch: jsonSafeFetch,
75
85
  }),
76
86
  ],
@@ -25,6 +25,9 @@ import { createPreloadTagWriter } from "./createPreloadTagWriter.js";
25
25
  import { createRequestTrpcClient } from "./createRequestTrpcClient.js";
26
26
  import { createStreamingStore } from "./createStreamingStore.js";
27
27
  import { stripTrailingSlash } from "../utils/stripTrailingSlash.js";
28
+ import { log as rootLog } from "@quark-fw/core/src/log.js";
29
+
30
+ const log = rootLog.child({ scope: "react" });
28
31
 
29
32
  export type GetRenderMiddlewareArgs = {
30
33
  /** dev: модули грузятся через vite; в prod — из собранного бандла */
@@ -214,14 +217,14 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
214
217
  stream.pipe(res);
215
218
  },
216
219
  onError: (error) => {
217
- console.error(error);
220
+ log.error("ssr stream failed", { err: error });
218
221
  res.end(serverErrorBody(error, "message"));
219
222
  },
220
223
  bootstrapModules,
221
224
  },
222
225
  });
223
226
  } catch (e: unknown) {
224
- console.error(e);
227
+ log.error("ssr failed", { err: e });
225
228
  res.status(500).end(serverErrorBody(e, "stack"));
226
229
  }
227
230
  };
@@ -0,0 +1,56 @@
1
+ import { expect, test } from "vitest";
2
+ import express from "express";
3
+ import type { Server } from "node:http";
4
+ import type { AddressInfo } from "node:net";
5
+ import { parseTrustProxy } from "./parseTrustProxy.js";
6
+
7
+ test("разбор значений TRUST_PROXY", () => {
8
+ expect(parseTrustProxy(undefined)).toBeUndefined();
9
+ expect(parseTrustProxy("")).toBeUndefined();
10
+ expect(parseTrustProxy("1")).toBe(1);
11
+ expect(parseTrustProxy(" 2 ")).toBe(2);
12
+ expect(parseTrustProxy("true")).toBe(true);
13
+ expect(parseTrustProxy("false")).toBe(false);
14
+ expect(parseTrustProxy("loopback, 10.0.0.0/8")).toBe(
15
+ "loopback, 10.0.0.0/8",
16
+ );
17
+ });
18
+
19
+ const withApp = async (
20
+ trust: ReturnType<typeof parseTrustProxy>,
21
+ fn: (base: string) => Promise<void>,
22
+ ) => {
23
+ const app = express();
24
+ if (trust !== undefined) app.set("trust proxy", trust);
25
+ app.get("/ip", (req, res) => res.json({ ip: req.ip }));
26
+ let server!: Server;
27
+ await new Promise<void>((resolve) => {
28
+ server = app.listen(0, () => resolve());
29
+ });
30
+ try {
31
+ await fn(`http://127.0.0.1:${(server.address() as AddressInfo).port}`);
32
+ } finally {
33
+ server.close();
34
+ }
35
+ };
36
+
37
+ const ipVia = async (base: string, forwarded?: string) => {
38
+ const r = await fetch(`${base}/ip`, {
39
+ headers: forwarded ? { "x-forwarded-for": forwarded } : {},
40
+ });
41
+ return ((await r.json()) as { ip: string }).ip;
42
+ };
43
+
44
+ test("TRUST_PROXY=1 — один хоп: req.ip берётся из X-Forwarded-For, цепочка не спуфится", async () => {
45
+ await withApp(parseTrustProxy("1"), async (base) => {
46
+ expect(await ipVia(base, "203.0.113.5")).toBe("203.0.113.5");
47
+ // клиент дописал свой заголовок перед прокси — верим только последнему
48
+ expect(await ipVia(base, "1.1.1.1, 203.0.113.5")).toBe("203.0.113.5");
49
+ });
50
+ });
51
+
52
+ test('строка "1" как раньше: заголовок игнорируется, req.ip — адрес сокета', async () => {
53
+ await withApp("1", async (base) => {
54
+ expect(await ipVia(base, "203.0.113.5")).toMatch(/127\.0\.0\.1$/);
55
+ });
56
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * TRUST_PROXY → значение express `trust proxy`.
3
+ *
4
+ * Строка "1" как есть трактуется express как список адресов и парсится в
5
+ * IP 0.0.0.1: ни один прокси не доверенный, req.ip — адрес прокси, req.secure
6
+ * — false. Поэтому число — это число доверенных хопов (Coolify/Traefik: 1),
7
+ * "true" — доверять всем X-Forwarded-* (небезопасно: клиент сам подставит
8
+ * IP), остальное — список адресов/подсетей (`loopback, 10.0.0.0/8`).
9
+ */
10
+ export function parseTrustProxy(
11
+ value: string | undefined,
12
+ ): boolean | number | string | undefined {
13
+ const trimmed = value?.trim();
14
+ if (!trimmed) return undefined;
15
+ if (trimmed === "true") return true;
16
+ if (trimmed === "false") return false;
17
+ if (/^\d+$/.test(trimmed)) return Number(trimmed);
18
+ return trimmed;
19
+ }