@quark-fw/plugin-react 0.1.14 → 0.1.16
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 +5 -5
- package/src/frontend/App.tsx +4 -1
- package/src/frontend/HTML/index.tsx +36 -3
- package/src/frontend/entry-server.tsx +9 -1
- package/src/seo/SeoProvider.tsx +10 -1
- package/src/seo/createPageMeta.test.ts +24 -0
- package/src/seo/createPageMeta.ts +19 -2
- package/src/ssr/getRenderMiddleware.ts +42 -7
- package/src/ssr/helpers.test.ts +47 -0
- package/src/ssr/helpers.ts +39 -1
- package/src/utils/createViteConfig.ts +109 -109
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quark-fw/plugin-react",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
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
|
-
"@quark-fw/plugin": "0.1.
|
|
25
|
+
"@quark-fw/atom": "0.1.16",
|
|
26
|
+
"@quark-fw/plugin": "0.1.16",
|
|
27
27
|
"@trpc/client": "^11.0.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.
|
|
40
|
-
"@quark-fw/store": "0.1.
|
|
39
|
+
"@quark-fw/core": "0.1.16",
|
|
40
|
+
"@quark-fw/store": "0.1.16",
|
|
41
41
|
"@vitejs/plugin-react": "^5.1.0",
|
|
42
42
|
"react": "^19.2.4",
|
|
43
43
|
"react-dom": "^19.2.4",
|
package/src/frontend/App.tsx
CHANGED
|
@@ -77,7 +77,10 @@ export default function App({
|
|
|
77
77
|
<ModalsProvider>
|
|
78
78
|
<TRPCContext value={trpcClient}>
|
|
79
79
|
<StoreContext value={store}>
|
|
80
|
-
<SeoProvider
|
|
80
|
+
<SeoProvider
|
|
81
|
+
seoObject={seoObject}
|
|
82
|
+
locale={locale || i18nConfig?.defaultLocale}
|
|
83
|
+
>
|
|
81
84
|
{i18nConfig ? (
|
|
82
85
|
<I18nProvider
|
|
83
86
|
locale={
|
|
@@ -5,6 +5,8 @@ import removeSearchParamsFromUrl from "../../utils/removeSearchParamsFromUrl.js"
|
|
|
5
5
|
import serializeForScript from "../../utils/serializeForScript.js";
|
|
6
6
|
import type { ResolvedSeo } from "../../seo/createPageMeta.js";
|
|
7
7
|
|
|
8
|
+
export type HTMLAlternate = { hreflang: string; href: string };
|
|
9
|
+
|
|
8
10
|
export type HTMLProps = {
|
|
9
11
|
children?: ReactNode;
|
|
10
12
|
colorScheme: "dark" | "light" | undefined;
|
|
@@ -15,6 +17,10 @@ export type HTMLProps = {
|
|
|
15
17
|
seo?: ResolvedSeo;
|
|
16
18
|
/** иконка вкладки; приложение задаёт её атомом quark.react.favicon */
|
|
17
19
|
favicon?: string;
|
|
20
|
+
/** абсолютный origin (https://host): делает canonical/og:url абсолютными */
|
|
21
|
+
origin?: string;
|
|
22
|
+
/** hreflang-кластер страницы (приложения с префиксами локалей) */
|
|
23
|
+
alternates?: HTMLAlternate[];
|
|
18
24
|
};
|
|
19
25
|
|
|
20
26
|
function HTML({
|
|
@@ -24,14 +30,29 @@ function HTML({
|
|
|
24
30
|
seoPathname,
|
|
25
31
|
seo,
|
|
26
32
|
favicon,
|
|
33
|
+
origin,
|
|
34
|
+
alternates,
|
|
27
35
|
}: HTMLProps) {
|
|
28
|
-
const
|
|
36
|
+
const path = removeSearchParamsFromUrl(pathname!);
|
|
37
|
+
// без origin остаётся относительный URL — как было до появления поля
|
|
38
|
+
const canonical = origin ? origin + path : path;
|
|
39
|
+
// относительная картинка og:image без origin бесполезна для соцсетей
|
|
40
|
+
const abs = (url?: string) =>
|
|
41
|
+
url && url.startsWith("/") && origin ? origin + url : url;
|
|
29
42
|
const og = seo?.og;
|
|
30
43
|
return (
|
|
31
44
|
<>
|
|
32
45
|
<head>
|
|
33
46
|
<title>{seo?.title ?? "App"}</title>
|
|
34
47
|
<link rel="canonical" href={canonical} />
|
|
48
|
+
{alternates?.map((alt) => (
|
|
49
|
+
<link
|
|
50
|
+
rel="alternate"
|
|
51
|
+
hrefLang={alt.hreflang}
|
|
52
|
+
href={alt.href}
|
|
53
|
+
key={alt.hreflang}
|
|
54
|
+
/>
|
|
55
|
+
))}
|
|
35
56
|
{favicon ? <link rel="icon" href={favicon} /> : null}
|
|
36
57
|
{seo?.description ? (
|
|
37
58
|
<meta name="description" content={seo.description} />
|
|
@@ -59,7 +80,7 @@ function HTML({
|
|
|
59
80
|
/>
|
|
60
81
|
) : null}
|
|
61
82
|
{og?.image ? (
|
|
62
|
-
<meta property="og:image" content={og.image} />
|
|
83
|
+
<meta property="og:image" content={abs(og.image)} />
|
|
63
84
|
) : null}
|
|
64
85
|
{og?.siteName ? (
|
|
65
86
|
<meta
|
|
@@ -67,8 +88,20 @@ function HTML({
|
|
|
67
88
|
content={og.siteName}
|
|
68
89
|
/>
|
|
69
90
|
) : null}
|
|
91
|
+
{og?.locale ? (
|
|
92
|
+
<meta property="og:locale" content={og.locale} />
|
|
93
|
+
) : null}
|
|
70
94
|
</>
|
|
71
95
|
) : null}
|
|
96
|
+
{seo?.jsonLD?.map((node, index) => (
|
|
97
|
+
<script
|
|
98
|
+
key={index}
|
|
99
|
+
type="application/ld+json"
|
|
100
|
+
dangerouslySetInnerHTML={{
|
|
101
|
+
__html: serializeForScript(node),
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
))}
|
|
72
105
|
{/* мета для клиентской навигации (SeoProvider) */}
|
|
73
106
|
<script
|
|
74
107
|
dangerouslySetInnerHTML={{
|
|
@@ -76,7 +109,7 @@ function HTML({
|
|
|
76
109
|
// (basename роутера уже срезан), иначе SeoProvider
|
|
77
110
|
// промахнётся и перезапросит мету заново
|
|
78
111
|
__html: `window.seo=${serializeForScript({
|
|
79
|
-
[seoPathname ??
|
|
112
|
+
[seoPathname ?? path]: seo ?? {},
|
|
80
113
|
})}`,
|
|
81
114
|
}}
|
|
82
115
|
/>
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
UglyModule,
|
|
13
13
|
ViteModulesEager,
|
|
14
14
|
} from "../Router/gatherPages.js";
|
|
15
|
-
import HTML from "./HTML/index.js";
|
|
15
|
+
import HTML, { type HTMLAlternate } from "./HTML/index.js";
|
|
16
16
|
import App from "./App.js";
|
|
17
17
|
import type { ResolvedSeo } from "../seo/createPageMeta.js";
|
|
18
18
|
import type { I18nConfig } from "../i18n/config.js";
|
|
@@ -51,6 +51,10 @@ export interface RendererPipeParams {
|
|
|
51
51
|
seoObject?: Record<string, ResolvedSeo>;
|
|
52
52
|
/** иконка вкладки из атома приложения */
|
|
53
53
|
favicon?: string;
|
|
54
|
+
/** абсолютный origin запроса (см. getRequestOrigin) */
|
|
55
|
+
origin?: string;
|
|
56
|
+
/** hreflang-кластер страницы */
|
|
57
|
+
alternates?: HTMLAlternate[];
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
export const hasUglyPage = (url: string) => getMatchedRoute(url, uglyPages);
|
|
@@ -87,6 +91,8 @@ export class Renderer {
|
|
|
87
91
|
req,
|
|
88
92
|
seo,
|
|
89
93
|
favicon,
|
|
94
|
+
origin,
|
|
95
|
+
alternates,
|
|
90
96
|
}: Omit<RendererPipeParams, "options">) {
|
|
91
97
|
return ReactDOMServer.renderToString(
|
|
92
98
|
<HTML
|
|
@@ -96,6 +102,8 @@ export class Renderer {
|
|
|
96
102
|
manifest={this.manifest}
|
|
97
103
|
seo={seo}
|
|
98
104
|
favicon={favicon}
|
|
105
|
+
origin={origin}
|
|
106
|
+
alternates={alternates}
|
|
99
107
|
/>,
|
|
100
108
|
);
|
|
101
109
|
}
|
package/src/seo/SeoProvider.tsx
CHANGED
|
@@ -9,9 +9,12 @@ import SeoContext, { type SeoContextValue, type SeoMap } from "./SeoContext.js";
|
|
|
9
9
|
export function SeoProvider({
|
|
10
10
|
children,
|
|
11
11
|
seoObject: initial,
|
|
12
|
+
locale,
|
|
12
13
|
}: {
|
|
13
14
|
children: ReactNode;
|
|
14
15
|
seoObject?: SeoMap;
|
|
16
|
+
/** активная локаль — её видят функции меты при клиентской навигации */
|
|
17
|
+
locale?: string;
|
|
15
18
|
}) {
|
|
16
19
|
const [seoObject, setSeoObject] = useState<SeoMap>(initial ?? {});
|
|
17
20
|
const { pathname } = useLocation();
|
|
@@ -39,6 +42,11 @@ export function SeoProvider({
|
|
|
39
42
|
const resolved = await resolveMeta(meta, {
|
|
40
43
|
store,
|
|
41
44
|
params: (routeProps?.params ?? {}) as Record<string, string>,
|
|
45
|
+
locale,
|
|
46
|
+
origin:
|
|
47
|
+
typeof window === "undefined"
|
|
48
|
+
? undefined
|
|
49
|
+
: window.location.origin,
|
|
42
50
|
});
|
|
43
51
|
if (!cancelled) {
|
|
44
52
|
setSeoObject((prev) => ({ ...prev, [pathname]: resolved }));
|
|
@@ -48,7 +56,8 @@ export function SeoProvider({
|
|
|
48
56
|
cancelled = true;
|
|
49
57
|
};
|
|
50
58
|
// store живёт всё время жизни приложения и меняться не может, а его
|
|
51
|
-
// добавление в зависимости ничего бы не
|
|
59
|
+
// добавление в зависимости ничего бы не изменило; locale меняется
|
|
60
|
+
// только полной перезагрузкой (setLocale)
|
|
52
61
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
53
62
|
}, [pathname, current]);
|
|
54
63
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { resolveMeta } from "./createPageMeta.js";
|
|
3
|
+
|
|
4
|
+
test("resolveMeta передаёт locale и origin в функции меты и резолвит jsonLD", async () => {
|
|
5
|
+
const ctx = {
|
|
6
|
+
store: {} as never,
|
|
7
|
+
params: { city: "tbilisi" },
|
|
8
|
+
locale: "ru",
|
|
9
|
+
origin: "https://bina.rent",
|
|
10
|
+
};
|
|
11
|
+
const seo = await resolveMeta(
|
|
12
|
+
{
|
|
13
|
+
title: ({ locale, params }) => `${locale}:${params["city"]}`,
|
|
14
|
+
jsonLD: ({ origin }) => [{ "@type": "WebSite", url: `${origin}/` }],
|
|
15
|
+
og: { locale: "ru_RU" },
|
|
16
|
+
},
|
|
17
|
+
ctx,
|
|
18
|
+
);
|
|
19
|
+
expect(seo.title).toBe("ru:tbilisi");
|
|
20
|
+
expect(seo.jsonLD).toEqual([
|
|
21
|
+
{ "@type": "WebSite", url: "https://bina.rent/" },
|
|
22
|
+
]);
|
|
23
|
+
expect(seo.og?.locale).toBe("ru_RU");
|
|
24
|
+
});
|
|
@@ -6,6 +6,14 @@ import type { TStore } from "@quark-fw/store";
|
|
|
6
6
|
export type PageMetaContext = {
|
|
7
7
|
store: TStore;
|
|
8
8
|
params: Record<string, string>;
|
|
9
|
+
/** активная локаль (только у приложений с /i18n.ts) */
|
|
10
|
+
locale?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Абсолютный origin запроса (https://host) — для абсолютных URL в
|
|
13
|
+
* JSON-LD и og-тегах. На SSR берётся из PUBLIC_ORIGIN или заголовков,
|
|
14
|
+
* на клиенте — из window.location.
|
|
15
|
+
*/
|
|
16
|
+
origin?: string;
|
|
9
17
|
};
|
|
10
18
|
|
|
11
19
|
export type MetaValue<T> = T | ((ctx: PageMetaContext) => T | Promise<T>);
|
|
@@ -16,14 +24,21 @@ export type PageMetaOG = {
|
|
|
16
24
|
image?: string;
|
|
17
25
|
type?: string;
|
|
18
26
|
siteName?: string;
|
|
27
|
+
/** og:locale, например ru_RU */
|
|
28
|
+
locale?: string;
|
|
19
29
|
};
|
|
20
30
|
|
|
31
|
+
/** узел JSON-LD; форма задаётся schema.org, здесь — только объект */
|
|
32
|
+
export type JsonLd = Record<string, unknown>;
|
|
33
|
+
|
|
21
34
|
export type PageMeta = {
|
|
22
35
|
title?: MetaValue<string>;
|
|
23
36
|
description?: MetaValue<string>;
|
|
24
37
|
/** например 'noindex, nofollow' */
|
|
25
38
|
robots?: MetaValue<string>;
|
|
26
39
|
og?: MetaValue<PageMetaOG | undefined>;
|
|
40
|
+
/** структурированные данные: по <script type="application/ld+json"> на узел */
|
|
41
|
+
jsonLD?: MetaValue<JsonLd[] | undefined>;
|
|
27
42
|
/** HTTP-статус ответа (например 404 для «не найдено») */
|
|
28
43
|
status?: MetaValue<number>;
|
|
29
44
|
};
|
|
@@ -33,6 +48,7 @@ export type ResolvedSeo = {
|
|
|
33
48
|
description?: string;
|
|
34
49
|
robots?: string;
|
|
35
50
|
og?: PageMetaOG;
|
|
51
|
+
jsonLD?: JsonLd[];
|
|
36
52
|
status?: number;
|
|
37
53
|
};
|
|
38
54
|
|
|
@@ -57,12 +73,13 @@ export async function resolveMeta(
|
|
|
57
73
|
ctx: PageMetaContext,
|
|
58
74
|
): Promise<ResolvedSeo> {
|
|
59
75
|
if (!meta) return {};
|
|
60
|
-
const [title, description, robots, og, status] = await Promise.all([
|
|
76
|
+
const [title, description, robots, og, jsonLD, status] = await Promise.all([
|
|
61
77
|
resolveValue(meta.title, ctx),
|
|
62
78
|
resolveValue(meta.description, ctx),
|
|
63
79
|
resolveValue(meta.robots, ctx),
|
|
64
80
|
resolveValue(meta.og, ctx),
|
|
81
|
+
resolveValue(meta.jsonLD, ctx),
|
|
65
82
|
resolveValue(meta.status, ctx),
|
|
66
83
|
]);
|
|
67
|
-
return { title, description, robots, og, status };
|
|
84
|
+
return { title, description, robots, og, jsonLD, status };
|
|
68
85
|
}
|
|
@@ -8,9 +8,15 @@ import {
|
|
|
8
8
|
buildLocalePath,
|
|
9
9
|
detectLocale,
|
|
10
10
|
parseLocalePath,
|
|
11
|
+
usesLocalePrefix,
|
|
11
12
|
type I18nConfig,
|
|
12
13
|
} from "../i18n/config.js";
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
getReactAtoms,
|
|
16
|
+
getRequestOrigin,
|
|
17
|
+
parseCookies,
|
|
18
|
+
serverErrorBody,
|
|
19
|
+
} from "./helpers.js";
|
|
14
20
|
import { resolveClientAssets } from "./resolveClientAssets.js";
|
|
15
21
|
import { createRendererLoader } from "./createRendererLoader.js";
|
|
16
22
|
import { createPreloadTagWriter } from "./createPreloadTagWriter.js";
|
|
@@ -80,8 +86,10 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
80
86
|
);
|
|
81
87
|
|
|
82
88
|
// мета страницы: резолвится ПЕРЕД стримом (title/description
|
|
83
|
-
// должны попасть в <head>), функции меты видят store
|
|
89
|
+
// должны попасть в <head>), функции меты видят store, params,
|
|
90
|
+
// локаль и origin
|
|
84
91
|
const pathname = strippedPathname;
|
|
92
|
+
const origin = getRequestOrigin(req);
|
|
85
93
|
const pageMeta = (
|
|
86
94
|
page?.module?.default as { meta?: PageMeta } | undefined
|
|
87
95
|
)?.meta;
|
|
@@ -98,10 +106,32 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
98
106
|
const seo = await resolveMeta(pageMeta, {
|
|
99
107
|
store,
|
|
100
108
|
params: (routeProps?.params ?? {}) as Record<string, string>,
|
|
109
|
+
locale: locale || undefined,
|
|
110
|
+
origin,
|
|
101
111
|
});
|
|
102
112
|
|
|
103
113
|
res.status(seo.status ?? (page ? 200 : 404));
|
|
104
114
|
|
|
115
|
+
// hreflang-кластер: каждая локаль + x-default на дефолтную
|
|
116
|
+
const alternates =
|
|
117
|
+
i18nConfig && usesLocalePrefix(i18nConfig)
|
|
118
|
+
? [
|
|
119
|
+
...i18nConfig.locales.map((l) => ({
|
|
120
|
+
hreflang: l,
|
|
121
|
+
href: origin + buildLocalePath(l, pathname),
|
|
122
|
+
})),
|
|
123
|
+
{
|
|
124
|
+
hreflang: "x-default",
|
|
125
|
+
href:
|
|
126
|
+
origin +
|
|
127
|
+
buildLocalePath(
|
|
128
|
+
i18nConfig.defaultLocale,
|
|
129
|
+
pathname,
|
|
130
|
+
),
|
|
131
|
+
},
|
|
132
|
+
]
|
|
133
|
+
: undefined;
|
|
134
|
+
|
|
105
135
|
const data = {
|
|
106
136
|
req,
|
|
107
137
|
registerModule,
|
|
@@ -110,8 +140,15 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
110
140
|
seo,
|
|
111
141
|
seoObject: { [pathname]: seo },
|
|
112
142
|
favicon: reactAtoms?.favicon?.value,
|
|
143
|
+
origin,
|
|
144
|
+
alternates,
|
|
113
145
|
};
|
|
114
|
-
|
|
146
|
+
// INJECT_HTML — разметка оператора (счётчики, verification), не
|
|
147
|
+
// пользовательский ввод; вставляется в конец <head> как есть
|
|
148
|
+
const injectHtml = process.env.INJECT_HTML ?? "";
|
|
149
|
+
const head = renderer
|
|
150
|
+
.head(data)
|
|
151
|
+
.replace(/<\/head>$/, `${injectHtml}</head>`);
|
|
115
152
|
// с i18n язык берётся из закрытого списка локалей, без него —
|
|
116
153
|
// из quark.react.lang (приложение задаёт его само)
|
|
117
154
|
const htmlLang = locale || reactAtoms?.lang?.value || "en";
|
|
@@ -129,16 +166,14 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
129
166
|
},
|
|
130
167
|
onError: (error) => {
|
|
131
168
|
console.error(error);
|
|
132
|
-
res.end(
|
|
133
|
-
`Server Error: ${error instanceof Error ? error.message : error}`,
|
|
134
|
-
);
|
|
169
|
+
res.end(serverErrorBody(error, "message"));
|
|
135
170
|
},
|
|
136
171
|
bootstrapModules,
|
|
137
172
|
},
|
|
138
173
|
});
|
|
139
174
|
} catch (e: unknown) {
|
|
140
175
|
console.error(e);
|
|
141
|
-
res.status(500).end(e
|
|
176
|
+
res.status(500).end(serverErrorBody(e, "stack"));
|
|
142
177
|
}
|
|
143
178
|
};
|
|
144
179
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { afterEach, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
afterEach(() => {
|
|
4
|
+
vi.unstubAllEnvs();
|
|
5
|
+
vi.resetModules();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// isProd читается при импорте, поэтому модуль грузим после stubEnv
|
|
9
|
+
const load = () => import("./helpers.js");
|
|
10
|
+
|
|
11
|
+
test("parseCookies не падает на кривой %-последовательности", async () => {
|
|
12
|
+
const { parseCookies } = await load();
|
|
13
|
+
expect(parseCookies("a=%E0%A4%A; b=%D0%BF")).toEqual({
|
|
14
|
+
a: "%E0%A4%A",
|
|
15
|
+
b: "п",
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("serverErrorBody в проде без подробностей", async () => {
|
|
20
|
+
vi.stubEnv("NODE_ENV", "production");
|
|
21
|
+
const { serverErrorBody } = await load();
|
|
22
|
+
expect(serverErrorBody(new Error("db password=x"), "stack")).toBe(
|
|
23
|
+
"Server Error",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("serverErrorBody в dev — со стеком", async () => {
|
|
28
|
+
vi.stubEnv("NODE_ENV", "development");
|
|
29
|
+
const { serverErrorBody } = await load();
|
|
30
|
+
const body = serverErrorBody(new Error("boom"), "stack");
|
|
31
|
+
expect(body).toContain("boom");
|
|
32
|
+
expect(body).toContain("helpers.test.ts");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("getRequestOrigin: PUBLIC_ORIGIN главнее заголовков, хвостовой / срезается", async () => {
|
|
36
|
+
vi.stubEnv("PUBLIC_ORIGIN", "https://bina.rent/");
|
|
37
|
+
const { getRequestOrigin } = await load();
|
|
38
|
+
const req = { protocol: "http", get: () => "internal:3000" };
|
|
39
|
+
expect(getRequestOrigin(req as never)).toBe("https://bina.rent");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("getRequestOrigin: без PUBLIC_ORIGIN — протокол и host запроса", async () => {
|
|
43
|
+
vi.stubEnv("PUBLIC_ORIGIN", "");
|
|
44
|
+
const { getRequestOrigin } = await load();
|
|
45
|
+
const req = { protocol: "https", get: () => "tt.drus.dev" };
|
|
46
|
+
expect(getRequestOrigin(req as never)).toBe("https://tt.drus.dev");
|
|
47
|
+
});
|
package/src/ssr/helpers.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Хелперы SSR-мидлвары.
|
|
2
|
+
import type { Request } from "express";
|
|
2
3
|
|
|
3
4
|
export const isProd = process.env.NODE_ENV === "production";
|
|
4
5
|
|
|
@@ -9,9 +10,32 @@ export const parseCookies = (header?: string): Record<string, string> =>
|
|
|
9
10
|
.split(";")
|
|
10
11
|
.map((part) => part.trim().split("="))
|
|
11
12
|
.filter((pair) => pair.length === 2)
|
|
12
|
-
.map(([k, v]) => [k!,
|
|
13
|
+
.map(([k, v]) => [k!, safeDecode(v!)]),
|
|
13
14
|
);
|
|
14
15
|
|
|
16
|
+
// кривая %-последовательность в чужой cookie не должна ронять рендер
|
|
17
|
+
// всей страницы в 500 — оставляем значение как есть
|
|
18
|
+
const safeDecode = (value: string) => {
|
|
19
|
+
try {
|
|
20
|
+
return decodeURIComponent(value);
|
|
21
|
+
} catch {
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Тело ответа при ошибке рендера. В проде — без подробностей: стек и
|
|
28
|
+
* сообщение раскрывают пути, версии и внутренности приложения.
|
|
29
|
+
*/
|
|
30
|
+
export const serverErrorBody = (
|
|
31
|
+
error: unknown,
|
|
32
|
+
detail: "message" | "stack",
|
|
33
|
+
): string => {
|
|
34
|
+
if (isProd) return "Server Error";
|
|
35
|
+
const text = error instanceof Error ? error[detail] : undefined;
|
|
36
|
+
return `Server Error: ${text ?? String(error)}`;
|
|
37
|
+
};
|
|
38
|
+
|
|
15
39
|
/**
|
|
16
40
|
* Атомы react-плагина. Объявлены в его аугментации Quark, но читаются
|
|
17
41
|
* структурно: значения нужны как есть, до готовности плагина.
|
|
@@ -24,3 +48,17 @@ export type ReactAtoms = {
|
|
|
24
48
|
|
|
25
49
|
export const getReactAtoms = (): ReactAtoms | undefined =>
|
|
26
50
|
(globalThis as { quark?: { react?: ReactAtoms } }).quark?.react;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Абсолютный origin запроса для canonical/hreflang/JSON-LD.
|
|
54
|
+
*
|
|
55
|
+
* PUBLIC_ORIGIN главнее заголовков: за прокси Host может быть внутренним, а
|
|
56
|
+
* канонический адрес сайта оператор знает точно. Без него — протокол и host
|
|
57
|
+
* из запроса (req.protocol учитывает X-Forwarded-Proto при TRUST_PROXY).
|
|
58
|
+
*/
|
|
59
|
+
export const getRequestOrigin = (req: Request): string => {
|
|
60
|
+
const configured = process.env.PUBLIC_ORIGIN?.trim();
|
|
61
|
+
if (configured) return configured.replace(/\/$/, "");
|
|
62
|
+
const host = req.get("host");
|
|
63
|
+
return host ? `${req.protocol}://${host}` : "";
|
|
64
|
+
};
|
|
@@ -1,109 +1,109 @@
|
|
|
1
|
-
import type { InlineConfig, PluginOption } 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
|
-
/**
|
|
45
|
-
* Реестр страниц (`import.meta.glob` по /Pages) лежит внутри самого плагина, то
|
|
46
|
-
* есть в node_modules приложения. Такие модули vite отдаёт с
|
|
47
|
-
* `Cache-Control: max-age=31536000, immutable`, считая их неизменяемыми, и
|
|
48
|
-
* версионирует хэшем зависимостей — а он не меняется от появления новой
|
|
49
|
-
* страницы в приложении. Браузер продолжает исполнять старый реестр: сервер
|
|
50
|
-
* страницу отрисовал, а клиентский роутер её маршрута не знает и после
|
|
51
|
-
* гидратации показывает 404 (лечилось только Ctrl+F5). Содержимое этого модуля
|
|
52
|
-
* зависит от файлов приложения, поэтому кэшировать его нельзя.
|
|
53
|
-
*/
|
|
54
|
-
const pageRegistryNoCachePlugin = (): PluginOption => ({
|
|
55
|
-
name: "quark:page-registry-no-cache",
|
|
56
|
-
apply: "serve",
|
|
57
|
-
configureServer(server) {
|
|
58
|
-
server.middlewares.use((req, res, next) => {
|
|
59
|
-
if (req.url?.includes("/Router/gatherPages.")) {
|
|
60
|
-
const setHeader = res.setHeader.bind(res);
|
|
61
|
-
res.setHeader = (name, value) =>
|
|
62
|
-
/^cache-control$/i.test(name)
|
|
63
|
-
? setHeader(name, "no-cache")
|
|
64
|
-
: setHeader(name, value);
|
|
65
|
-
}
|
|
66
|
-
next();
|
|
67
|
-
});
|
|
68
|
-
},
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
/** Единая конфигурация vite для dev-сервера и для сборки. */
|
|
72
|
-
export function createViteConfig(overrides?: InlineConfig): InlineConfig {
|
|
73
|
-
return {
|
|
74
|
-
plugins: [
|
|
75
|
-
patchCssModules(),
|
|
76
|
-
svgr({ include: "**/*.svg" }),
|
|
77
|
-
react(),
|
|
78
|
-
tsconfigPaths(),
|
|
79
|
-
loadModulesFromExternalFolderPlugin(),
|
|
80
|
-
pageRegistryNoCachePlugin(),
|
|
81
|
-
],
|
|
82
|
-
// Установленный из реестра плагин лежит в node_modules, куда
|
|
83
|
-
// @vitejs/plugin-react не заглядывает (у него exclude /node_modules/).
|
|
84
|
-
// Без явного automatic-рантайма его собственный JSX собирается в
|
|
85
|
-
// React.createElement и падает с "React is not defined".
|
|
86
|
-
esbuild: { jsx: "automatic", jsxImportSource: "react" },
|
|
87
|
-
optimizeDeps: {
|
|
88
|
-
exclude: workspacePackages(process.cwd()),
|
|
89
|
-
// Плагин исключён из оптимизации, поэтому vite не сканирует его
|
|
90
|
-
// импорты: установленный из реестра, он тянет react-router и mantine
|
|
91
|
-
// сырыми, и CJS-пакеты внутри них (cookie) падают на named-импортах.
|
|
92
|
-
// В монорепо, наоборот, форсировать пре-бандл нельзя — рядом с
|
|
93
|
-
// симлинкованными исходниками появилась бы вторая копия React.
|
|
94
|
-
include: installedFromRegistry
|
|
95
|
-
? [
|
|
96
|
-
"react",
|
|
97
|
-
"react-dom",
|
|
98
|
-
"react-dom/client",
|
|
99
|
-
"react-router-dom",
|
|
100
|
-
"@mantine/core",
|
|
101
|
-
"@mantine/dates",
|
|
102
|
-
"@mantine/modals",
|
|
103
|
-
"@mantine/notifications",
|
|
104
|
-
]
|
|
105
|
-
: [],
|
|
106
|
-
},
|
|
107
|
-
...overrides,
|
|
108
|
-
};
|
|
109
|
-
}
|
|
1
|
+
import type { InlineConfig, PluginOption } 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
|
+
/**
|
|
45
|
+
* Реестр страниц (`import.meta.glob` по /Pages) лежит внутри самого плагина, то
|
|
46
|
+
* есть в node_modules приложения. Такие модули vite отдаёт с
|
|
47
|
+
* `Cache-Control: max-age=31536000, immutable`, считая их неизменяемыми, и
|
|
48
|
+
* версионирует хэшем зависимостей — а он не меняется от появления новой
|
|
49
|
+
* страницы в приложении. Браузер продолжает исполнять старый реестр: сервер
|
|
50
|
+
* страницу отрисовал, а клиентский роутер её маршрута не знает и после
|
|
51
|
+
* гидратации показывает 404 (лечилось только Ctrl+F5). Содержимое этого модуля
|
|
52
|
+
* зависит от файлов приложения, поэтому кэшировать его нельзя.
|
|
53
|
+
*/
|
|
54
|
+
const pageRegistryNoCachePlugin = (): PluginOption => ({
|
|
55
|
+
name: "quark:page-registry-no-cache",
|
|
56
|
+
apply: "serve",
|
|
57
|
+
configureServer(server) {
|
|
58
|
+
server.middlewares.use((req, res, next) => {
|
|
59
|
+
if (req.url?.includes("/Router/gatherPages.")) {
|
|
60
|
+
const setHeader = res.setHeader.bind(res);
|
|
61
|
+
res.setHeader = (name, value) =>
|
|
62
|
+
/^cache-control$/i.test(name)
|
|
63
|
+
? setHeader(name, "no-cache")
|
|
64
|
+
: setHeader(name, value);
|
|
65
|
+
}
|
|
66
|
+
next();
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** Единая конфигурация vite для dev-сервера и для сборки. */
|
|
72
|
+
export function createViteConfig(overrides?: InlineConfig): InlineConfig {
|
|
73
|
+
return {
|
|
74
|
+
plugins: [
|
|
75
|
+
patchCssModules(),
|
|
76
|
+
svgr({ include: "**/*.svg" }),
|
|
77
|
+
react(),
|
|
78
|
+
tsconfigPaths(),
|
|
79
|
+
loadModulesFromExternalFolderPlugin(),
|
|
80
|
+
pageRegistryNoCachePlugin(),
|
|
81
|
+
],
|
|
82
|
+
// Установленный из реестра плагин лежит в node_modules, куда
|
|
83
|
+
// @vitejs/plugin-react не заглядывает (у него exclude /node_modules/).
|
|
84
|
+
// Без явного automatic-рантайма его собственный JSX собирается в
|
|
85
|
+
// React.createElement и падает с "React is not defined".
|
|
86
|
+
esbuild: { jsx: "automatic", jsxImportSource: "react" },
|
|
87
|
+
optimizeDeps: {
|
|
88
|
+
exclude: workspacePackages(process.cwd()),
|
|
89
|
+
// Плагин исключён из оптимизации, поэтому vite не сканирует его
|
|
90
|
+
// импорты: установленный из реестра, он тянет react-router и mantine
|
|
91
|
+
// сырыми, и CJS-пакеты внутри них (cookie) падают на named-импортах.
|
|
92
|
+
// В монорепо, наоборот, форсировать пре-бандл нельзя — рядом с
|
|
93
|
+
// симлинкованными исходниками появилась бы вторая копия React.
|
|
94
|
+
include: installedFromRegistry
|
|
95
|
+
? [
|
|
96
|
+
"react",
|
|
97
|
+
"react-dom",
|
|
98
|
+
"react-dom/client",
|
|
99
|
+
"react-router-dom",
|
|
100
|
+
"@mantine/core",
|
|
101
|
+
"@mantine/dates",
|
|
102
|
+
"@mantine/modals",
|
|
103
|
+
"@mantine/notifications",
|
|
104
|
+
]
|
|
105
|
+
: [],
|
|
106
|
+
},
|
|
107
|
+
...overrides,
|
|
108
|
+
};
|
|
109
|
+
}
|