@quark-fw/plugin-react 0.1.7 → 0.1.9

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.
@@ -1,16 +1,5 @@
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";
1
+ import { createContext, useContext } from "react";
2
+ import type { ResolvedSeo } from "./createPageMeta.js";
14
3
 
15
4
  export type SeoMap = Record<string, ResolvedSeo>;
16
5
 
@@ -26,69 +15,4 @@ const SeoContext = createContext<SeoContextValue>({
26
15
 
27
16
  export const useSeo = () => useContext(SeoContext);
28
17
 
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
18
  export default SeoContext;
@@ -0,0 +1,72 @@
1
+ import { ReactNode, useEffect, useMemo, useState } from "react";
2
+ import { useLocation } from "react-router-dom";
3
+ import { useStore } from "@quark-fw/store";
4
+ import { matchPath } from "react-router-dom";
5
+ import { getMatchedRoute } from "../Router/getMatchedRoute.js";
6
+ import type { PageMeta } from "./createPageMeta.js";
7
+ import SeoContext, { type SeoContextValue, type SeoMap } from "./SeoContext.js";
8
+
9
+ export function SeoProvider({
10
+ children,
11
+ seoObject: initial,
12
+ }: {
13
+ children: ReactNode;
14
+ seoObject?: SeoMap;
15
+ }) {
16
+ const [seoObject, setSeoObject] = useState<SeoMap>(initial ?? {});
17
+ const { pathname } = useLocation();
18
+ const store = useStore();
19
+
20
+ const current = seoObject[pathname];
21
+
22
+ // клиентская навигация: меты для нового пути ещё нет — резолвим её
23
+ // из модуля страницы (тот же resolveMeta, что и на сервере)
24
+ useEffect(() => {
25
+ if (current) return;
26
+ let cancelled = false;
27
+ void (async () => {
28
+ const uglyModule = getMatchedRoute(pathname);
29
+ if (!uglyModule) return;
30
+ const loaded = await uglyModule.load();
31
+ const meta = (loaded?.default as { meta?: PageMeta } | undefined)
32
+ ?.meta;
33
+ if (!meta || cancelled) return;
34
+ const routeProps = matchPath(
35
+ { path: uglyModule.urlPath, end: false, caseSensitive: false },
36
+ pathname,
37
+ );
38
+ const { resolveMeta } = await import("./createPageMeta.js");
39
+ const resolved = await resolveMeta(meta, {
40
+ store,
41
+ params: (routeProps?.params ?? {}) as Record<string, string>,
42
+ });
43
+ if (!cancelled) {
44
+ setSeoObject((prev) => ({ ...prev, [pathname]: resolved }));
45
+ }
46
+ })();
47
+ return () => {
48
+ cancelled = true;
49
+ };
50
+ // store живёт всё время жизни приложения и меняться не может, а его
51
+ // добавление в зависимости ничего бы не изменило
52
+ // eslint-disable-next-line react-hooks/exhaustive-deps
53
+ }, [pathname, current]);
54
+
55
+ // заголовок вкладки при клиентской навигации
56
+ useEffect(() => {
57
+ if (typeof document === "undefined") return;
58
+ if (current?.title) document.title = current.title;
59
+ }, [current?.title]);
60
+
61
+ const value = useMemo<SeoContextValue>(
62
+ () => ({
63
+ ...(current ?? {}),
64
+ seoObject,
65
+ setPathnameSeo: (path, seo) =>
66
+ setSeoObject((prev) => ({ ...prev, [path]: seo })),
67
+ }),
68
+ [current, seoObject],
69
+ );
70
+
71
+ return <SeoContext.Provider value={value}>{children}</SeoContext.Provider>;
72
+ }
@@ -0,0 +1,36 @@
1
+ import type { Manifest } from "vite";
2
+ import type { Response } from "express";
3
+ import { UglyModule } from "../Router/UglyModule.js";
4
+
5
+ /**
6
+ * Пишет в ответ preload-теги (modulepreload + css) для модулей страницы,
7
+ * которые регистрирует роутер по мере рендера. Каждый css — один раз.
8
+ */
9
+ export function createPreloadTagWriter(manifest: Manifest, res: Response) {
10
+ const requiredModules: UglyModule[] = [];
11
+ const written: Record<string, boolean> = {};
12
+
13
+ const extractTags = (link: string): void => {
14
+ const chunk = manifest[link];
15
+ if (chunk && chunk.name !== "entry-client") {
16
+ res.write(
17
+ `<link rel="modulepreload" as="script" crossorigin href="/${chunk.file}">`,
18
+ );
19
+ (chunk.css ?? []).forEach((cssFile) => {
20
+ if (!written[cssFile]) {
21
+ written[cssFile] = true;
22
+ res.write(`<link rel="stylesheet" href="/${cssFile}"/>`);
23
+ }
24
+ });
25
+ (chunk.imports ?? []).map((childLink) => extractTags(childLink));
26
+ }
27
+ };
28
+
29
+ return function registerModule(uglyModule: UglyModule) {
30
+ if (!requiredModules.includes(uglyModule)) {
31
+ requiredModules.push(uglyModule);
32
+
33
+ extractTags(uglyModule.modulePath.substring(1));
34
+ }
35
+ };
36
+ }
@@ -0,0 +1,29 @@
1
+ import type { ViteDevServer } from "vite";
2
+ import path from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { buildPaths } from "../utils/viteConfig.js";
5
+ import { isProd } from "./helpers.js";
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+
9
+ type RendererModule = typeof import("../frontend/entry-server.js");
10
+
11
+ /**
12
+ * Загрузчик SSR-бандла: в prod импортируется один раз из сборки,
13
+ * в dev — через vite (с HMR).
14
+ */
15
+ export function createRendererLoader(vite?: ViteDevServer) {
16
+ let prodRenderer: Promise<RendererModule>;
17
+
18
+ return function loadRenderer(): Promise<RendererModule> {
19
+ if (isProd) {
20
+ prodRenderer ??= import(
21
+ pathToFileURL(buildPaths().serverEntry).href
22
+ ) as Promise<RendererModule>;
23
+ return prodRenderer;
24
+ }
25
+ return vite!.ssrLoadModule(
26
+ path.join(__dirname, "../frontend/entry-server.js"),
27
+ ) as Promise<RendererModule>;
28
+ };
29
+ }
@@ -0,0 +1,23 @@
1
+ import { createTRPCClient, httpLink } from "@trpc/client";
2
+ import { jsonSafeFetch, type TRPCClient } from "@quark-fw/store";
3
+
4
+ /**
5
+ * tRPC-клиент КОНКРЕТНОГО запроса: SSR ходит к собственному серверу по
6
+ * HTTP и пробрасывает cookie, чтобы страница рендерилась от имени
7
+ * залогиненного пользователя. Клиент собирается здесь, а роутер объявляет
8
+ * приложение — тот же шов аугментации, что и в сторе.
9
+ */
10
+ export function createRequestTrpcClient(
11
+ port: number,
12
+ cookie: string | undefined,
13
+ ): TRPCClient {
14
+ return createTRPCClient({
15
+ links: [
16
+ httpLink({
17
+ url: `http://127.0.0.1:${port}/api/trpc`,
18
+ headers: cookie ? { cookie } : {},
19
+ fetch: jsonSafeFetch,
20
+ }),
21
+ ],
22
+ }) as TRPCClient;
23
+ }
@@ -0,0 +1,47 @@
1
+ import type { Response } from "express";
2
+ import Store, { NOT_READY_STATE, type TRPCClient } from "@quark-fw/store";
3
+ import serializeForScript from "../utils/serializeForScript.js";
4
+
5
+ /**
6
+ * Store, стримящий резолв данных в ответ по мере рендера: клиентский Store
7
+ * гидратируется из window.cache без повторных запросов, NOT_READY_STATE
8
+ * будит зависшие Suspense-чтения.
9
+ *
10
+ * Мета страницы резолвится ДО записи <head>, а её tRPC-вызовы дёргают
11
+ * callback store — писать их сразу нельзя: заголовки ушли бы раньше
12
+ * res.status(), а скрипты — раньше <!doctype>. Поэтому записи кэша ждут
13
+ * releaseCacheWrites(), который вызывается после записи shell.
14
+ */
15
+ export function createStreamingStore(trpcClient: TRPCClient, res: Response) {
16
+ let htmlReadyResolve!: () => void;
17
+ const htmlReady = new Promise<void>((resolve) => {
18
+ htmlReadyResolve = resolve;
19
+ });
20
+
21
+ const store = new Store({
22
+ trpcClient,
23
+ callback: (key, value) => {
24
+ const safeKey = serializeForScript(key);
25
+ void htmlReady.then(() => {
26
+ if (value === NOT_READY_STATE) {
27
+ res.write(
28
+ `<script>window.cache=window.cache||{};window.cache[${safeKey}]=${serializeForScript(NOT_READY_STATE)};</script>`,
29
+ );
30
+ } else {
31
+ // undefined сериализуем как null: literal undefined в
32
+ // window.cache читается клиентом как cache miss и
33
+ // приводит к повторному suspense при гидратации
34
+ res.write(
35
+ `<script>window.cache[${safeKey}]=${
36
+ typeof value === "undefined"
37
+ ? "null"
38
+ : serializeForScript(value)
39
+ };window.store?.emit(${safeKey},window.cache[${safeKey}]);</script>`,
40
+ );
41
+ }
42
+ });
43
+ },
44
+ });
45
+
46
+ return { store, releaseCacheWrites: htmlReadyResolve };
47
+ }
@@ -1,13 +1,8 @@
1
- import { Manifest, ViteDevServer } from "vite";
1
+ import { ViteDevServer } from "vite";
2
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
3
  import { matchPath } from "react-router-dom";
4
+ import { UglyModule } from "../Router/UglyModule.js";
5
+ import removeSearchParamsFromUrl from "../utils/removeSearchParamsFromUrl.js";
11
6
  import { resolveMeta, type PageMeta } from "../seo/createPageMeta.js";
12
7
  import {
13
8
  buildLocalePath,
@@ -15,63 +10,21 @@ import {
15
10
  parseLocalePath,
16
11
  type I18nConfig,
17
12
  } 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";
13
+ import { getReactAtoms, parseCookies } from "./helpers.js";
14
+ import { resolveClientAssets } from "./resolveClientAssets.js";
15
+ import { createRendererLoader } from "./createRendererLoader.js";
16
+ import { createPreloadTagWriter } from "./createPreloadTagWriter.js";
17
+ import { createRequestTrpcClient } from "./createRequestTrpcClient.js";
18
+ import { createStreamingStore } from "./createStreamingStore.js";
30
19
 
31
20
  export type GetRenderMiddlewareArgs = {
32
21
  /** dev: модули грузятся через vite; в prod — из собранного бандла */
33
22
  vite?: ViteDevServer;
34
23
  };
35
24
 
36
- const isProd = process.env.NODE_ENV === "production";
37
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
-
39
25
  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
- };
26
+ const { manifest, bootstrapModules } = resolveClientAssets();
27
+ const loadRenderer = createRendererLoader(vite);
75
28
 
76
29
  return async function rendererMiddleware(req: Request, res: Response) {
77
30
  try {
@@ -114,99 +67,17 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
114
67
  const page = rendererModule.hasUglyPage(strippedPathname) as
115
68
  UglyModule | undefined;
116
69
 
117
- const requiredModules: UglyModule[] = [];
70
+ const registerModule = createPreloadTagWriter(manifest, res);
118
71
 
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({
72
+ const reactAtoms = getReactAtoms();
73
+ const trpcClient = createRequestTrpcClient(
74
+ reactAtoms?.port?.value ?? 3000,
75
+ req.headers.cookie,
76
+ );
77
+ const { store, releaseCacheWrites } = createStreamingStore(
187
78
  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
- });
79
+ res,
80
+ );
210
81
 
211
82
  // мета страницы: резолвится ПЕРЕД стримом (title/description
212
83
  // должны попасть в <head>), функции меты видят store и params
@@ -248,7 +119,7 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
248
119
  res.write(head);
249
120
  res.write(`<body><div id="root">`);
250
121
  // shell записан — отпускаем отложенные записи кэша
251
- htmlReadyResolve();
122
+ releaseCacheWrites();
252
123
 
253
124
  const stream = renderer.pipe({
254
125
  ...data,
@@ -0,0 +1,26 @@
1
+ // Хелперы SSR-мидлвары.
2
+
3
+ export const isProd = process.env.NODE_ENV === "production";
4
+
5
+ /** Разбор Cookie-заголовка без внешних зависимостей. */
6
+ export const parseCookies = (header?: string): Record<string, string> =>
7
+ Object.fromEntries(
8
+ (header ?? "")
9
+ .split(";")
10
+ .map((part) => part.trim().split("="))
11
+ .filter((pair) => pair.length === 2)
12
+ .map(([k, v]) => [k!, decodeURIComponent(v!)]),
13
+ );
14
+
15
+ /**
16
+ * Атомы react-плагина. Объявлены в его аугментации Quark, но читаются
17
+ * структурно: значения нужны как есть, до готовности плагина.
18
+ */
19
+ export type ReactAtoms = {
20
+ port?: { value?: number };
21
+ lang?: { value?: string };
22
+ favicon?: { value?: string };
23
+ };
24
+
25
+ export const getReactAtoms = (): ReactAtoms | undefined =>
26
+ (globalThis as { quark?: { react?: ReactAtoms } }).quark?.react;
@@ -0,0 +1,31 @@
1
+ import type { Manifest } from "vite";
2
+ import * as fs from "node:fs";
3
+ import { buildPaths } from "../utils/viteConfig.js";
4
+ import { isProd } from "./helpers.js";
5
+
6
+ /**
7
+ * Клиентские ассеты рендера: манифест сборки и entry-модули для
8
+ * bootstrapModules. В dev манифеста нет — vite отдаёт исходник напрямую.
9
+ */
10
+ export function resolveClientAssets(): {
11
+ manifest: Manifest;
12
+ bootstrapModules: string[];
13
+ } {
14
+ const paths = buildPaths();
15
+
16
+ const manifest = (
17
+ isProd ? JSON.parse(fs.readFileSync(paths.manifest, "utf8")) : {}
18
+ ) as Manifest;
19
+
20
+ if (!isProd) return { manifest, bootstrapModules: ["/entry-client.tsx"] };
21
+
22
+ const entry = Object.entries(manifest).find(([key]) =>
23
+ key.includes("entry-client"),
24
+ )?.[1];
25
+ if (!entry) {
26
+ throw new Error(
27
+ "[react] entry-client not found in build manifest — запусти сборку (npm run build)",
28
+ );
29
+ }
30
+ return { manifest, bootstrapModules: [`/${entry.file}`] };
31
+ }
@@ -0,0 +1,81 @@
1
+ import type { InlineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import tsconfigPaths from "vite-tsconfig-paths";
4
+ import svgr from "vite-plugin-svgr";
5
+ import { patchCssModules } from "vite-css-modules";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { loadModulesFromExternalFolderPlugin } from "./plugins.js";
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ /**
14
+ * Пакеты @quark/* в приложении — это симлинки на ИСХОДНИКИ соседних
15
+ * workspace-пакетов. Если позволить vite их пре-бандлить, он кэширует
16
+ * результат в node_modules/.vite и продолжает отдавать старую версию даже
17
+ * после правок в пакете (правка видна только после ручной очистки кэша).
18
+ * Поэтому исключаем их из оптимизации — тогда HMR работает как для
19
+ * обычных файлов приложения.
20
+ */
21
+ const workspacePackages = (cwd: string): string[] => {
22
+ try {
23
+ const pkg = JSON.parse(
24
+ fs.readFileSync(path.join(cwd, "package.json"), "utf8"),
25
+ ) as Record<string, Record<string, string> | undefined>;
26
+ return Object.keys({
27
+ ...(pkg.dependencies ?? {}),
28
+ ...(pkg.devDependencies ?? {}),
29
+ }).filter(
30
+ (name) =>
31
+ name.startsWith("@quark/") || name.startsWith("@quark-fw/"),
32
+ );
33
+ } catch {
34
+ return [];
35
+ }
36
+ };
37
+
38
+ /** Плагин поставлен из npm (лежит внутри node_modules), а не симлинкован из монорепы. */
39
+ const installedFromRegistry = fs
40
+ .realpathSync(__dirname)
41
+ .replace(/\\/g, "/")
42
+ .includes("/node_modules/");
43
+
44
+ /** Единая конфигурация vite для dev-сервера и для сборки. */
45
+ export function createViteConfig(overrides?: InlineConfig): InlineConfig {
46
+ return {
47
+ plugins: [
48
+ patchCssModules(),
49
+ svgr({ include: "**/*.svg" }),
50
+ react(),
51
+ tsconfigPaths(),
52
+ loadModulesFromExternalFolderPlugin(),
53
+ ],
54
+ // Установленный из реестра плагин лежит в node_modules, куда
55
+ // @vitejs/plugin-react не заглядывает (у него exclude /node_modules/).
56
+ // Без явного automatic-рантайма его собственный JSX собирается в
57
+ // React.createElement и падает с "React is not defined".
58
+ esbuild: { jsx: "automatic", jsxImportSource: "react" },
59
+ optimizeDeps: {
60
+ exclude: workspacePackages(process.cwd()),
61
+ // Плагин исключён из оптимизации, поэтому vite не сканирует его
62
+ // импорты: установленный из реестра, он тянет react-router и mantine
63
+ // сырыми, и CJS-пакеты внутри них (cookie) падают на named-импортах.
64
+ // В монорепо, наоборот, форсировать пре-бандл нельзя — рядом с
65
+ // симлинкованными исходниками появилась бы вторая копия React.
66
+ include: installedFromRegistry
67
+ ? [
68
+ "react",
69
+ "react-dom",
70
+ "react-dom/client",
71
+ "react-router-dom",
72
+ "@mantine/core",
73
+ "@mantine/dates",
74
+ "@mantine/modals",
75
+ "@mantine/notifications",
76
+ ]
77
+ : [],
78
+ },
79
+ ...overrides,
80
+ };
81
+ }
package/src/utils/vite.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createServer, ViteDevServer } from "vite";
2
- import { createViteConfig } from "./viteConfig.js";
2
+ import { createViteConfig } from "./createViteConfig.js";
3
3
 
4
4
  let vite: ViteDevServer | null = null;
5
5
 
@@ -20,8 +20,15 @@ export async function getVite({
20
20
  strictPort: true,
21
21
  middlewareMode: true,
22
22
  // у каждого приложения свой HMR-сокет: иначе вторая
23
- // запущенная апка ловит "Port 24678 is already in use"
24
- hmr: { port: appPort + 20000 },
23
+ // запущенная апка ловит "Port 24678 is already in use".
24
+ // Высокие appPort заворачиваются в валидный диапазон,
25
+ // иначе ERR_SOCKET_BAD_PORT (порт > 65535)
26
+ hmr: {
27
+ port:
28
+ appPort + 20000 <= 65535
29
+ ? appPort + 20000
30
+ : 1024 + ((appPort + 20000) % 64512),
31
+ },
25
32
  },
26
33
  }),
27
34
  );