@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.
- package/package.json +12 -10
- package/src/Router/PageComponentRenderer.tsx +21 -0
- package/src/Router/Router.tsx +5 -178
- package/src/Router/UglyModule.test.ts +48 -0
- package/src/Router/UglyModule.ts +98 -0
- package/src/Router/UglyModuleRenderer.tsx +69 -0
- package/src/Router/UglyRouter.tsx +77 -0
- package/src/Router/createUglyModules.ts +32 -0
- package/src/Router/gatherPages.ts +7 -149
- package/src/Router/types.ts +30 -0
- package/src/build.ts +1 -1
- package/src/frontend/App.tsx +6 -4
- package/src/frontend/entry-client.tsx +4 -2
- package/src/frontend/entry-server.tsx +1 -1
- package/src/i18n/I18nContext.tsx +1 -191
- package/src/i18n/I18nProvider.tsx +168 -0
- package/src/i18n/helpers.ts +29 -0
- package/src/seo/SeoContext.tsx +2 -78
- package/src/seo/SeoProvider.tsx +72 -0
- package/src/ssr/createPreloadTagWriter.ts +36 -0
- package/src/ssr/createRendererLoader.ts +29 -0
- package/src/ssr/createRequestTrpcClient.ts +23 -0
- package/src/ssr/createStreamingStore.ts +47 -0
- package/src/ssr/getRenderMiddleware.ts +21 -150
- package/src/ssr/helpers.ts +26 -0
- package/src/ssr/resolveClientAssets.ts +31 -0
- package/src/utils/createViteConfig.ts +81 -0
- package/src/utils/vite.ts +10 -3
- package/src/utils/viteConfig.ts +2 -76
- package/src/utils/usePromise.ts +0 -24
package/src/seo/SeoContext.tsx
CHANGED
|
@@ -1,16 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
|
70
|
+
const registerModule = createPreloadTagWriter(manifest, res);
|
|
118
71
|
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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 "./
|
|
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
|
-
|
|
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
|
);
|