@quark-fw/plugin-react 0.1.2

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.
@@ -0,0 +1,112 @@
1
+ export type I18nConfig = {
2
+ /** коды локалей: они же префиксы URL (/{locale}/…) и вход для Intl */
3
+ locales: readonly string[];
4
+ /** язык-фолбэк: его словарь подмешивается под все остальные */
5
+ defaultLocale: string;
6
+ /** отдавать страницы без префикса; учитывается только при одной локали */
7
+ urlPrefix?: boolean;
8
+ };
9
+
10
+ export const defineI18nConfig = <const T extends I18nConfig>(config: T): T =>
11
+ config;
12
+
13
+ let invalidPrefixWarned = false;
14
+
15
+ /**
16
+ * Единственный источник правды о том, несут ли URL префикс /{locale}.
17
+ * urlPrefix: false осмыслен только при одной локали — иначе многоязычный
18
+ * сайт молча схлопнулся бы в один язык.
19
+ */
20
+ export function usesLocalePrefix(config?: I18nConfig): boolean {
21
+ if (!config?.locales?.length) return false;
22
+
23
+ if (config.urlPrefix === false && config.locales.length > 1) {
24
+ if (!invalidPrefixWarned) {
25
+ invalidPrefixWarned = true;
26
+ console.error(
27
+ `[i18n] urlPrefix: false требует ровно одну локаль, получено ${config.locales.length} (${config.locales.join(", ")}) — префиксы оставлены`,
28
+ );
29
+ }
30
+ return true;
31
+ }
32
+
33
+ return config.urlPrefix !== false;
34
+ }
35
+
36
+ export type ParsedLocalePath = {
37
+ locale: string;
38
+ strippedPathname: string;
39
+ basename: string;
40
+ /** путь без валидного префикса — нужен редирект */
41
+ redirect: boolean;
42
+ };
43
+
44
+ export function parseLocalePath(
45
+ pathname: string,
46
+ config?: I18nConfig,
47
+ ): ParsedLocalePath {
48
+ if (!config?.locales?.length) {
49
+ return {
50
+ locale: "",
51
+ strippedPathname: pathname,
52
+ basename: "",
53
+ redirect: false,
54
+ };
55
+ }
56
+ if (!usesLocalePrefix(config)) {
57
+ return {
58
+ locale: config.defaultLocale,
59
+ strippedPathname: pathname,
60
+ basename: "",
61
+ redirect: false,
62
+ };
63
+ }
64
+ const segment = pathname.split("/")[1];
65
+ if (segment && config.locales.includes(segment)) {
66
+ return {
67
+ locale: segment,
68
+ strippedPathname: pathname.slice(segment.length + 1) || "/",
69
+ basename: `/${segment}`,
70
+ redirect: false,
71
+ };
72
+ }
73
+ return {
74
+ locale: config.defaultLocale,
75
+ strippedPathname: pathname,
76
+ basename: "",
77
+ redirect: true,
78
+ };
79
+ }
80
+
81
+ export function buildLocalePath(locale: string, strippedPath: string): string {
82
+ return `/${locale}${strippedPath.startsWith("/") ? strippedPath : `/${strippedPath}`}`;
83
+ }
84
+
85
+ /** Локаль по cookie, затем по Accept-Language, иначе — дефолтная. */
86
+ export function detectLocale(
87
+ cookieValue: string | undefined,
88
+ acceptLanguage: string | undefined,
89
+ config: I18nConfig,
90
+ ): string {
91
+ if (cookieValue && config.locales.includes(cookieValue)) return cookieValue;
92
+
93
+ if (acceptLanguage) {
94
+ const candidates = acceptLanguage
95
+ .split(",")
96
+ .map((part) => {
97
+ const [tag, ...params] = part.trim().split(";");
98
+ const qParam = params.find((p) => p.trim().startsWith("q="));
99
+ const q = qParam ? Number(qParam.trim().slice(2)) : 1;
100
+ return {
101
+ lang: tag!.trim().split("-")[0]!.toLowerCase(),
102
+ q: Number.isFinite(q) ? q : 0,
103
+ };
104
+ })
105
+ .sort((a, b) => b.q - a.q);
106
+ for (const { lang } of candidates) {
107
+ if (config.locales.includes(lang)) return lang;
108
+ }
109
+ }
110
+
111
+ return config.defaultLocale;
112
+ }
@@ -0,0 +1,12 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ /** словарь переводов: строки и вложенные словари */
4
+ export type LocaleDict = { [key: string]: string | LocaleDict };
5
+
6
+ export type LocaleModule = { default?: LocaleDict };
7
+
8
+ // /locales/{lang}/{namespace}.ts — ровно один уровень вложенности
9
+ const lazyLocales = import.meta.glob<LocaleModule>("/locales/*/*.ts");
10
+
11
+ export const getLocaleImporter = (lang: string, namespace: string) =>
12
+ lazyLocales[`/locales/${lang}/${namespace}.ts`];
package/src/index.html ADDED
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>react</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
package/src/index.ts ADDED
@@ -0,0 +1,102 @@
1
+ import atom from "@quark-fw/atom";
2
+ import express from "express";
3
+ import fs from "node:fs";
4
+ import type { Server } from "node:http";
5
+ import type { ViteDevServer } from "vite";
6
+ import getRenderMiddleware from "./ssr/getRenderMiddleware.js";
7
+ import { getVite } from "./utils/vite.js";
8
+ import { appFrontendRoot, buildPaths } from "./utils/viteConfig.js";
9
+ import { httpRouter, preRouter } from "@quark-fw/plugin/http.js";
10
+ import {} from "@quark-fw/plugin-react/src/global.js";
11
+
12
+ const isProd = process.env.NODE_ENV === "production";
13
+
14
+ let server: Server | undefined;
15
+ let vite: ViteDevServer | undefined;
16
+ let unsubscribePort: (() => void) | undefined;
17
+
18
+ await quark.registerPlugin({
19
+ name: "react",
20
+ extend(quark) {
21
+ quark.react = {
22
+ port: atom("react.port", Number(process.env.PORT) || 3000),
23
+ lang: atom("react.lang", "en"),
24
+ // пусто по умолчанию: без иконки ссылка не выводится, а не
25
+ // указывает на несуществующий файл
26
+ favicon: atom("react.favicon", ""),
27
+ };
28
+ },
29
+ async onReady(quark) {
30
+ const app = express();
31
+
32
+ // за обратным прокси (TLS-терминация) нужно доверять X-Forwarded-*,
33
+ // иначе 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
+ }
38
+
39
+ // сквозные middleware (сессии, passport) — раньше всех обработчиков
40
+ app.use(preRouter);
41
+ // маршруты плагинов (например /api/trpc) — до статики и SSR
42
+ app.use(httpRouter);
43
+
44
+ if (isProd) {
45
+ const paths = buildPaths();
46
+ if (!fs.existsSync(paths.serverEntry)) {
47
+ throw new Error(
48
+ `[react] сборка не найдена (${paths.serverEntry}) — запусти npm run build`,
49
+ );
50
+ }
51
+ // хешированные ассеты кэшируются навсегда, остальное — нет
52
+ app.use(
53
+ "/assets",
54
+ express.static(`${paths.client}/assets`, {
55
+ immutable: true,
56
+ maxAge: "1y",
57
+ }),
58
+ );
59
+ app.use(express.static(paths.client, { index: false }));
60
+ const staticDir = `${paths.root}/static`;
61
+ if (fs.existsSync(staticDir)) app.use(express.static(staticDir));
62
+ app.use(getRenderMiddleware({}));
63
+ } else {
64
+ vite = await getVite({
65
+ root: appFrontendRoot(),
66
+ appPort: quark.react.port.value,
67
+ });
68
+ app.use(vite.middlewares);
69
+ app.use(getRenderMiddleware({ vite }));
70
+ }
71
+
72
+ const listen = (port: number) => {
73
+ server = app.listen(port, () => {
74
+ console.log(
75
+ `[react] ${isProd ? "production" : "dev"} http://localhost:${port}`,
76
+ );
77
+ });
78
+ };
79
+
80
+ listen(quark.react.port.value);
81
+
82
+ // при смене порта перезапускаем listener
83
+ unsubscribePort = quark.react.port.subscribe((port) => {
84
+ const old = server;
85
+ server = undefined;
86
+ if (old) {
87
+ old.close(() => listen(port));
88
+ old.closeAllConnections();
89
+ } else {
90
+ listen(port);
91
+ }
92
+ });
93
+ },
94
+ async onDestroy() {
95
+ unsubscribePort?.();
96
+ unsubscribePort = undefined;
97
+ server?.close();
98
+ server = undefined;
99
+ await vite?.close();
100
+ vite = undefined;
101
+ },
102
+ });
@@ -0,0 +1,94 @@
1
+ import {
2
+ createContext,
3
+ ReactNode,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ } from "react";
9
+ import { useLocation } from "react-router-dom";
10
+ import { useStore } from "@quark-fw/store";
11
+ import { matchPath } from "react-router-dom";
12
+ import { getMatchedRoute } from "../Router/getMatchedRoute.js";
13
+ import type { PageMeta, ResolvedSeo } from "./createPageMeta.js";
14
+
15
+ export type SeoMap = Record<string, ResolvedSeo>;
16
+
17
+ export type SeoContextValue = ResolvedSeo & {
18
+ seoObject: SeoMap;
19
+ setPathnameSeo: (pathname: string, seo: ResolvedSeo) => void;
20
+ };
21
+
22
+ const SeoContext = createContext<SeoContextValue>({
23
+ seoObject: {},
24
+ setPathnameSeo: () => {},
25
+ });
26
+
27
+ export const useSeo = () => useContext(SeoContext);
28
+
29
+ export function SeoProvider({
30
+ children,
31
+ seoObject: initial,
32
+ }: {
33
+ children: ReactNode;
34
+ seoObject?: SeoMap;
35
+ }) {
36
+ const [seoObject, setSeoObject] = useState<SeoMap>(initial ?? {});
37
+ const { pathname } = useLocation();
38
+ const store = useStore();
39
+
40
+ const current = seoObject[pathname];
41
+
42
+ // клиентская навигация: меты для нового пути ещё нет — резолвим её
43
+ // из модуля страницы (тот же resolveMeta, что и на сервере)
44
+ useEffect(() => {
45
+ if (current) return;
46
+ let cancelled = false;
47
+ void (async () => {
48
+ const uglyModule = getMatchedRoute(pathname);
49
+ if (!uglyModule) return;
50
+ const loaded = await uglyModule.load();
51
+ const meta = (loaded?.default as { meta?: PageMeta } | undefined)
52
+ ?.meta;
53
+ if (!meta || cancelled) return;
54
+ const routeProps = matchPath(
55
+ { path: uglyModule.urlPath, end: false, caseSensitive: false },
56
+ pathname,
57
+ );
58
+ const { resolveMeta } = await import("./createPageMeta.js");
59
+ const resolved = await resolveMeta(meta, {
60
+ store,
61
+ params: (routeProps?.params ?? {}) as Record<string, string>,
62
+ });
63
+ if (!cancelled) {
64
+ setSeoObject((prev) => ({ ...prev, [pathname]: resolved }));
65
+ }
66
+ })();
67
+ return () => {
68
+ cancelled = true;
69
+ };
70
+ // store живёт всё время жизни приложения и меняться не может, а его
71
+ // добавление в зависимости ничего бы не изменило
72
+ // eslint-disable-next-line react-hooks/exhaustive-deps
73
+ }, [pathname, current]);
74
+
75
+ // заголовок вкладки при клиентской навигации
76
+ useEffect(() => {
77
+ if (typeof document === "undefined") return;
78
+ if (current?.title) document.title = current.title;
79
+ }, [current?.title]);
80
+
81
+ const value = useMemo<SeoContextValue>(
82
+ () => ({
83
+ ...(current ?? {}),
84
+ seoObject,
85
+ setPathnameSeo: (path, seo) =>
86
+ setSeoObject((prev) => ({ ...prev, [path]: seo })),
87
+ }),
88
+ [current, seoObject],
89
+ );
90
+
91
+ return <SeoContext.Provider value={value}>{children}</SeoContext.Provider>;
92
+ }
93
+
94
+ export default SeoContext;
@@ -0,0 +1,68 @@
1
+ import type { TStore } from "@quark-fw/store";
2
+
3
+ // Порт createPageMeta из ugliest: страница объявляет мету, каждое поле —
4
+ // значение ИЛИ (возможно асинхронная) функция, которая резолвится против
5
+ // store во время SSR. Так title/description могут зависеть от данных.
6
+ export type PageMetaContext = {
7
+ store: TStore;
8
+ params: Record<string, string>;
9
+ };
10
+
11
+ export type MetaValue<T> = T | ((ctx: PageMetaContext) => T | Promise<T>);
12
+
13
+ export type PageMetaOG = {
14
+ title?: string;
15
+ description?: string;
16
+ image?: string;
17
+ type?: string;
18
+ siteName?: string;
19
+ };
20
+
21
+ export type PageMeta = {
22
+ title?: MetaValue<string>;
23
+ description?: MetaValue<string>;
24
+ /** например 'noindex, nofollow' */
25
+ robots?: MetaValue<string>;
26
+ og?: MetaValue<PageMetaOG | undefined>;
27
+ /** HTTP-статус ответа (например 404 для «не найдено») */
28
+ status?: MetaValue<number>;
29
+ };
30
+
31
+ export type ResolvedSeo = {
32
+ title?: string;
33
+ description?: string;
34
+ robots?: string;
35
+ og?: PageMetaOG;
36
+ status?: number;
37
+ };
38
+
39
+ export function createPageMeta(meta: PageMeta): PageMeta {
40
+ return meta;
41
+ }
42
+
43
+ const resolveValue = async <T>(
44
+ value: MetaValue<T> | undefined,
45
+ ctx: PageMetaContext,
46
+ ): Promise<T | undefined> => {
47
+ if (typeof value === "undefined") return undefined;
48
+ if (typeof value === "function") {
49
+ return (value as (c: PageMetaContext) => T | Promise<T>)(ctx);
50
+ }
51
+ return value;
52
+ };
53
+
54
+ /** Резолвит все поля меты параллельно; используется и на SSR, и на клиенте. */
55
+ export async function resolveMeta(
56
+ meta: PageMeta | undefined,
57
+ ctx: PageMetaContext,
58
+ ): Promise<ResolvedSeo> {
59
+ if (!meta) return {};
60
+ const [title, description, robots, og, status] = await Promise.all([
61
+ resolveValue(meta.title, ctx),
62
+ resolveValue(meta.description, ctx),
63
+ resolveValue(meta.robots, ctx),
64
+ resolveValue(meta.og, ctx),
65
+ resolveValue(meta.status, ctx),
66
+ ]);
67
+ return { title, description, robots, og, status };
68
+ }
@@ -0,0 +1,273 @@
1
+ import { Manifest, ViteDevServer } from "vite";
2
+ import { Request, Response } from "express";
3
+ import path from "node:path";
4
+ import * as fs from "node:fs";
5
+ import { createTRPCClient, httpLink } from "@trpc/client";
6
+ import Store, { jsonSafeFetch, type TRPCClient } from "@quark-fw/store";
7
+ import { UglyModule } from "../Router/gatherPages.js";
8
+ import removeSearchParamsFromUrl from "../utils/removeSearchParamsFromUrl.js";
9
+ import serializeForScript from "../utils/serializeForScript.js";
10
+ import { matchPath } from "react-router-dom";
11
+ import { resolveMeta, type PageMeta } from "../seo/createPageMeta.js";
12
+ import {
13
+ buildLocalePath,
14
+ detectLocale,
15
+ parseLocalePath,
16
+ type I18nConfig,
17
+ } from "../i18n/config.js";
18
+
19
+ /** Разбор Cookie-заголовка без внешних зависимостей. */
20
+ const parseCookies = (header?: string): Record<string, string> =>
21
+ Object.fromEntries(
22
+ (header ?? "")
23
+ .split(";")
24
+ .map((part) => part.trim().split("="))
25
+ .filter((pair) => pair.length === 2)
26
+ .map(([k, v]) => [k!, decodeURIComponent(v!)]),
27
+ );
28
+ import { fileURLToPath, pathToFileURL } from "node:url";
29
+ import { buildPaths } from "../utils/viteConfig.js";
30
+
31
+ export type GetRenderMiddlewareArgs = {
32
+ /** dev: модули грузятся через vite; в prod — из собранного бандла */
33
+ vite?: ViteDevServer;
34
+ };
35
+
36
+ const isProd = process.env.NODE_ENV === "production";
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
+
39
+ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
40
+ const paths = buildPaths();
41
+
42
+ const manifest = (
43
+ isProd ? JSON.parse(fs.readFileSync(paths.manifest, "utf8")) : {}
44
+ ) as Manifest;
45
+
46
+ const findManifestEntry = (fragment: string) =>
47
+ Object.entries(manifest).find(([key]) => key.includes(fragment))?.[1];
48
+
49
+ let bootstrapModules: string[];
50
+ if (isProd) {
51
+ const entry = findManifestEntry("entry-client");
52
+ if (!entry) {
53
+ throw new Error(
54
+ "[react] entry-client not found in build manifest — запусти сборку (npm run build)",
55
+ );
56
+ }
57
+ bootstrapModules = [`/${entry.file}`];
58
+ } else {
59
+ bootstrapModules = ["/entry-client.tsx"];
60
+ }
61
+
62
+ // в prod SSR-бандл импортируется один раз, в dev — через vite (HMR)
63
+ let prodRenderer: Promise<typeof import("../frontend/entry-server.js")>;
64
+ const loadRenderer = () => {
65
+ if (isProd) {
66
+ prodRenderer ??= import(
67
+ pathToFileURL(paths.serverEntry).href
68
+ ) as Promise<typeof import("../frontend/entry-server.js")>;
69
+ return prodRenderer;
70
+ }
71
+ return vite!.ssrLoadModule(
72
+ path.join(__dirname, "../frontend/entry-server.js"),
73
+ ) as Promise<typeof import("../frontend/entry-server.js")>;
74
+ };
75
+
76
+ return async function rendererMiddleware(req: Request, res: Response) {
77
+ try {
78
+ const rendererModule = await loadRenderer();
79
+
80
+ // i18n: определяем локаль по префиксу пути; голый путь при
81
+ // включённых префиксах уводим 302 на локаль из cookie/заголовка
82
+ const i18nConfig = (rendererModule as { i18nConfig?: I18nConfig })
83
+ .i18nConfig;
84
+ const urlWithoutSearch = removeSearchParamsFromUrl(req.originalUrl);
85
+ const { locale, strippedPathname, basename, redirect } =
86
+ parseLocalePath(urlWithoutSearch, i18nConfig);
87
+
88
+ if (redirect && i18nConfig) {
89
+ const target = detectLocale(
90
+ parseCookies(req.headers.cookie)["locale"],
91
+ req.headers["accept-language"],
92
+ i18nConfig,
93
+ );
94
+ const searchIndex = req.originalUrl.indexOf("?");
95
+ const search =
96
+ searchIndex === -1
97
+ ? ""
98
+ : req.originalUrl.slice(searchIndex);
99
+ res.redirect(
100
+ 302,
101
+ buildLocalePath(target, urlWithoutSearch) + search,
102
+ );
103
+ return;
104
+ }
105
+
106
+ const renderer = new rendererModule.Renderer({
107
+ url: req.originalUrl,
108
+ manifest,
109
+ locale,
110
+ basename,
111
+ strippedPathname,
112
+ });
113
+
114
+ const page = rendererModule.hasUglyPage(strippedPathname) as
115
+ UglyModule | undefined;
116
+
117
+ const requiredModules: UglyModule[] = [];
118
+
119
+ const written: Record<string, boolean> = {};
120
+ const extractTags = (link: string): void => {
121
+ const _module = manifest[link];
122
+ if (_module && _module.name !== "entry-client") {
123
+ res.write(
124
+ `<link rel="modulepreload" as="script" crossorigin href="/${_module.file}">`,
125
+ );
126
+ (_module.css ?? []).forEach((e) => {
127
+ if (!written[e]) {
128
+ written[e] = true;
129
+ res.write(`<link rel="stylesheet" href="/${e}"/>`);
130
+ }
131
+ });
132
+ (_module.imports ?? []).map((_link) => extractTags(_link));
133
+ }
134
+ };
135
+ const registerModule = (_module: UglyModule) => {
136
+ if (!requiredModules.includes(_module)) {
137
+ requiredModules.push(_module);
138
+
139
+ extractTags(_module.modulePath.substring(1));
140
+ }
141
+ };
142
+
143
+ // SSR-запросы к tRPC идут в собственный сервер по HTTP (dev-режим).
144
+ // Атомы плагина объявлены в его аугментации Quark, но здесь берём
145
+ // их структурно: значения нужны как есть, до готовности плагина.
146
+ const reactAtoms = (
147
+ globalThis as {
148
+ quark?: {
149
+ react?: {
150
+ port?: { value?: number };
151
+ lang?: { value?: string };
152
+ favicon?: { value?: string };
153
+ };
154
+ };
155
+ }
156
+ ).quark?.react;
157
+ const port = reactAtoms?.port?.value ?? 3000;
158
+ // клиент собирается здесь, а роутер объявляет приложение — тот же
159
+ // шов, что и в сторе
160
+ const trpcClient = createTRPCClient({
161
+ links: [
162
+ httpLink({
163
+ url: `http://127.0.0.1:${port}/api/trpc`,
164
+ // пробрасываем cookie запроса, чтобы SSR рендерил
165
+ // страницу от имени залогиненного пользователя
166
+ headers: req.headers.cookie
167
+ ? { cookie: req.headers.cookie }
168
+ : {},
169
+ fetch: jsonSafeFetch,
170
+ }),
171
+ ],
172
+ }) as TRPCClient;
173
+
174
+ // Мета страницы резолвится ДО записи <head>, а её tRPC-вызовы
175
+ // дёргают callback store — писать их сразу нельзя: заголовки
176
+ // ушли бы раньше res.status(), а скрипты — раньше <!doctype>.
177
+ // Поэтому все записи кэша ждут, пока shell не будет записан.
178
+ let htmlReadyResolve!: () => void;
179
+ const htmlReady = new Promise<void>((resolve) => {
180
+ htmlReadyResolve = resolve;
181
+ });
182
+
183
+ // по мере резолва данных при рендере стримим их в ответ:
184
+ // клиентский Store гидратируется из window.cache без повторных
185
+ // запросов; NOT_READY_STATE будит зависшие Suspense-чтения
186
+ const store = new Store({
187
+ trpcClient,
188
+ callback: (key, value) => {
189
+ const safeKey = serializeForScript(key);
190
+ void htmlReady.then(() => {
191
+ if (value === "NOT_READY_STATE") {
192
+ res.write(
193
+ `<script>window.cache=window.cache||{};window.cache[${safeKey}]='NOT_READY_STATE';</script>`,
194
+ );
195
+ } else {
196
+ // undefined сериализуем как null: literal undefined в
197
+ // window.cache читается клиентом как cache miss и
198
+ // приводит к повторному suspense при гидратации
199
+ res.write(
200
+ `<script>window.cache[${safeKey}]=${
201
+ typeof value === "undefined"
202
+ ? "null"
203
+ : serializeForScript(value)
204
+ };window.store?.emit(${safeKey},window.cache[${safeKey}]);</script>`,
205
+ );
206
+ }
207
+ });
208
+ },
209
+ });
210
+
211
+ // мета страницы: резолвится ПЕРЕД стримом (title/description
212
+ // должны попасть в <head>), функции меты видят store и params
213
+ const pathname = strippedPathname;
214
+ const pageMeta = (
215
+ page?.module?.default as { meta?: PageMeta } | undefined
216
+ )?.meta;
217
+ const routeProps = page
218
+ ? matchPath(
219
+ {
220
+ path: page.urlPath,
221
+ end: false,
222
+ caseSensitive: false,
223
+ },
224
+ pathname,
225
+ )
226
+ : null;
227
+ const seo = await resolveMeta(pageMeta, {
228
+ store,
229
+ params: (routeProps?.params ?? {}) as Record<string, string>,
230
+ });
231
+
232
+ res.status(seo.status ?? (page ? 200 : 404));
233
+
234
+ const data = {
235
+ req,
236
+ registerModule,
237
+ store,
238
+ trpcClient,
239
+ seo,
240
+ seoObject: { [pathname]: seo },
241
+ favicon: reactAtoms?.favicon?.value,
242
+ };
243
+ const head = renderer.head(data);
244
+ // с i18n язык берётся из закрытого списка локалей, без него —
245
+ // из quark.react.lang (приложение задаёт его само)
246
+ const htmlLang = locale || reactAtoms?.lang?.value || "en";
247
+ res.write(`<!doctype html><html lang="${htmlLang}">`);
248
+ res.write(head);
249
+ res.write(`<body><div id="root">`);
250
+ // shell записан — отпускаем отложенные записи кэша
251
+ htmlReadyResolve();
252
+
253
+ const stream = renderer.pipe({
254
+ ...data,
255
+ options: {
256
+ onShellReady: () => {
257
+ stream.pipe(res);
258
+ },
259
+ onError: (error) => {
260
+ console.error(error);
261
+ res.end(
262
+ `Server Error: ${error instanceof Error ? error.message : error}`,
263
+ );
264
+ },
265
+ bootstrapModules,
266
+ },
267
+ });
268
+ } catch (e: unknown) {
269
+ console.error(e);
270
+ res.status(500).end(e instanceof Error ? e.stack : e);
271
+ }
272
+ };
273
+ }