@quark-fw/plugin-react 0.1.23 → 0.1.25
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 +7 -6
- package/src/frontend/HTML/index.tsx +11 -3
- package/src/frontend/entry-server.tsx +10 -1
- package/src/seo/SeoProvider.tsx +8 -4
- package/src/seo/createPageMeta.test.ts +18 -0
- package/src/seo/createPageMeta.ts +15 -0
- package/src/ssr/createRequestTrpcClient.test.ts +167 -0
- package/src/ssr/createRequestTrpcClient.ts +64 -9
- package/src/ssr/getRenderMiddleware.ts +31 -8
- package/src/ssr/helpers.ts +15 -1
- package/src/utils/parseSearch.test.ts +20 -0
- package/src/utils/parseSearch.ts +27 -0
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.25",
|
|
4
4
|
"main": "src/index.ts",
|
|
5
5
|
"types": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
"access": "public"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@quark-fw/atom": "0.1.
|
|
26
|
-
"@quark-fw/plugin": "0.1.
|
|
27
|
-
"@trpc/client": "
|
|
25
|
+
"@quark-fw/atom": "0.1.25",
|
|
26
|
+
"@quark-fw/plugin": "0.1.25",
|
|
27
|
+
"@trpc/client": "~11.18.0",
|
|
28
28
|
"@types/react-dom": "^19.2.3",
|
|
29
29
|
"express": "^5.2.1",
|
|
30
30
|
"vite-css-modules": "^1.13.0",
|
|
@@ -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.25",
|
|
40
|
+
"@quark-fw/store": "0.1.25",
|
|
41
41
|
"@vitejs/plugin-react": "^5.1.0",
|
|
42
42
|
"react": "^19.2.4",
|
|
43
43
|
"react-dom": "^19.2.4",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"vite": "^7.1.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
+
"@trpc/server": "~11.18.0",
|
|
48
49
|
"@types/express": "^5.0.6",
|
|
49
50
|
"@types/react": "^19.2.14",
|
|
50
51
|
"vitest": "^5.0.0"
|
|
@@ -15,7 +15,7 @@ export type HTMLProps = {
|
|
|
15
15
|
colorScheme: "dark" | "light" | undefined;
|
|
16
16
|
manifest: Manifest;
|
|
17
17
|
pathname?: string;
|
|
18
|
-
/** путь БЕЗ префикса локали — по
|
|
18
|
+
/** путь БЕЗ префикса локали + search — ключ, по которому SeoProvider ищет мету */
|
|
19
19
|
seoPathname?: string;
|
|
20
20
|
seo?: ResolvedSeo;
|
|
21
21
|
/** иконка вкладки; приложение задаёт её атомом quark.react.favicon */
|
|
@@ -40,8 +40,16 @@ function HTML({
|
|
|
40
40
|
linkBase,
|
|
41
41
|
}: HTMLProps) {
|
|
42
42
|
const path = removeSearchParamsFromUrl(pathname!);
|
|
43
|
-
//
|
|
44
|
-
|
|
43
|
+
// canonical из меты: относительный (от basename) дополняется linkBase,
|
|
44
|
+
// абсолютный берётся как есть; без меты — путь без query. Без origin
|
|
45
|
+
// остаётся относительный URL — как было до появления поля
|
|
46
|
+
const canonical = seo?.canonical
|
|
47
|
+
? seo.canonical.startsWith("/")
|
|
48
|
+
? (linkBase ?? "") + seo.canonical
|
|
49
|
+
: seo.canonical
|
|
50
|
+
: origin
|
|
51
|
+
? origin + path
|
|
52
|
+
: path;
|
|
45
53
|
// относительная картинка og:image без origin бесполезна для соцсетей
|
|
46
54
|
const abs = (url?: string) =>
|
|
47
55
|
url && url.startsWith("/") && origin ? origin + url : url;
|
|
@@ -65,6 +65,8 @@ export class Renderer {
|
|
|
65
65
|
locale?: string;
|
|
66
66
|
basename?: string;
|
|
67
67
|
strippedPathname?: string;
|
|
68
|
+
/** нормализованный search запроса ("" или "?a=1") — часть ключа SEO-карты */
|
|
69
|
+
search: string;
|
|
68
70
|
|
|
69
71
|
constructor({
|
|
70
72
|
url,
|
|
@@ -72,18 +74,21 @@ export class Renderer {
|
|
|
72
74
|
locale,
|
|
73
75
|
basename,
|
|
74
76
|
strippedPathname,
|
|
77
|
+
search = "",
|
|
75
78
|
}: {
|
|
76
79
|
url: string;
|
|
77
80
|
manifest: Manifest;
|
|
78
81
|
locale?: string;
|
|
79
82
|
basename?: string;
|
|
80
83
|
strippedPathname?: string;
|
|
84
|
+
search?: string;
|
|
81
85
|
}) {
|
|
82
86
|
this.url = url;
|
|
83
87
|
this.manifest = manifest;
|
|
84
88
|
this.locale = locale;
|
|
85
89
|
this.basename = basename;
|
|
86
90
|
this.strippedPathname = strippedPathname;
|
|
91
|
+
this.search = search;
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
head({
|
|
@@ -97,7 +102,11 @@ export class Renderer {
|
|
|
97
102
|
return ReactDOMServer.renderToString(
|
|
98
103
|
<HTML
|
|
99
104
|
pathname={req.originalUrl}
|
|
100
|
-
seoPathname={
|
|
105
|
+
seoPathname={
|
|
106
|
+
this.strippedPathname === undefined
|
|
107
|
+
? undefined
|
|
108
|
+
: this.strippedPathname + this.search
|
|
109
|
+
}
|
|
101
110
|
colorScheme={colorScheme}
|
|
102
111
|
manifest={this.manifest}
|
|
103
112
|
seo={seo}
|
package/src/seo/SeoProvider.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { matchPath } from "react-router-dom";
|
|
|
5
5
|
import { getMatchedRoute } from "../Router/getMatchedRoute.js";
|
|
6
6
|
import type { PageMeta } from "./createPageMeta.js";
|
|
7
7
|
import SeoContext, { type SeoContextValue, type SeoMap } from "./SeoContext.js";
|
|
8
|
+
import { parseSearch, seoKey } from "../utils/parseSearch.js";
|
|
8
9
|
|
|
9
10
|
export function SeoProvider({
|
|
10
11
|
children,
|
|
@@ -17,11 +18,13 @@ export function SeoProvider({
|
|
|
17
18
|
locale?: string;
|
|
18
19
|
}) {
|
|
19
20
|
const [seoObject, setSeoObject] = useState<SeoMap>(initial ?? {});
|
|
20
|
-
const { pathname } = useLocation();
|
|
21
|
+
const { pathname, search } = useLocation();
|
|
21
22
|
const navigate = useNavigate();
|
|
22
23
|
const store = useStore();
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
// ключ с search: ?page=2 — другая мета (title, canonical), чем страница 1
|
|
26
|
+
const key = seoKey(pathname, search);
|
|
27
|
+
const current = seoObject[key];
|
|
25
28
|
|
|
26
29
|
// редирект из меты при клиентской навигации (на SSR его сделала мидлвара)
|
|
27
30
|
useEffect(() => {
|
|
@@ -56,9 +59,10 @@ export function SeoProvider({
|
|
|
56
59
|
typeof window === "undefined"
|
|
57
60
|
? undefined
|
|
58
61
|
: window.location.origin,
|
|
62
|
+
query: parseSearch(search).query,
|
|
59
63
|
});
|
|
60
64
|
if (!cancelled) {
|
|
61
|
-
setSeoObject((prev) => ({ ...prev, [
|
|
65
|
+
setSeoObject((prev) => ({ ...prev, [key]: resolved }));
|
|
62
66
|
}
|
|
63
67
|
})();
|
|
64
68
|
return () => {
|
|
@@ -68,7 +72,7 @@ export function SeoProvider({
|
|
|
68
72
|
// добавление в зависимости ничего бы не изменило; locale меняется
|
|
69
73
|
// только полной перезагрузкой (setLocale)
|
|
70
74
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
71
|
-
}, [
|
|
75
|
+
}, [key, current]);
|
|
72
76
|
|
|
73
77
|
// заголовок вкладки при клиентской навигации
|
|
74
78
|
useEffect(() => {
|
|
@@ -23,6 +23,24 @@ test("resolveMeta передаёт locale и origin в функции меты
|
|
|
23
23
|
expect(seo.og?.locale).toBe("ru_RU");
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
test("resolveMeta передаёт query и резолвит canonical", async () => {
|
|
27
|
+
const seo = await resolveMeta(
|
|
28
|
+
{
|
|
29
|
+
canonical: ({ query }) =>
|
|
30
|
+
Number(query?.["page"]) > 1
|
|
31
|
+
? `/tbilisi?page=${query!["page"]}`
|
|
32
|
+
: undefined,
|
|
33
|
+
},
|
|
34
|
+
{ store: {} as never, params: {}, query: { page: "2" } },
|
|
35
|
+
);
|
|
36
|
+
expect(seo.canonical).toBe("/tbilisi?page=2");
|
|
37
|
+
const first = await resolveMeta(
|
|
38
|
+
{ canonical: ({ query }) => (query?.["page"] ? "/x" : undefined) },
|
|
39
|
+
{ store: {} as never, params: {} },
|
|
40
|
+
);
|
|
41
|
+
expect(first.canonical).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
26
44
|
test("breadcrumbsJsonLd строит BreadcrumbList с абсолютными @id", () => {
|
|
27
45
|
const node = breadcrumbsJsonLd(
|
|
28
46
|
[
|
|
@@ -14,6 +14,11 @@ export type PageMetaContext = {
|
|
|
14
14
|
* на клиенте — из window.location.
|
|
15
15
|
*/
|
|
16
16
|
origin?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Query-параметры URL (первое значение каждого). Для страниц, чья мета
|
|
19
|
+
* зависит от запроса — например пагинация каталога (?page=N).
|
|
20
|
+
*/
|
|
21
|
+
query?: Record<string, string>;
|
|
17
22
|
};
|
|
18
23
|
|
|
19
24
|
export type MetaValue<T> = T | ((ctx: PageMetaContext) => T | Promise<T>);
|
|
@@ -58,6 +63,12 @@ export type PageMeta = {
|
|
|
58
63
|
* смена slug), чтобы не отдавать 404 по проиндексированным адресам.
|
|
59
64
|
*/
|
|
60
65
|
redirect?: MetaValue<PageMetaRedirect | undefined>;
|
|
66
|
+
/**
|
|
67
|
+
* Canonical страницы: путь относительно basename (может содержать query,
|
|
68
|
+
* например `/tbilisi?page=2`) или абсолютный URL. По умолчанию — путь
|
|
69
|
+
* страницы без query. hreflang-кластер и og:url следуют за canonical.
|
|
70
|
+
*/
|
|
71
|
+
canonical?: MetaValue<string | undefined>;
|
|
61
72
|
};
|
|
62
73
|
|
|
63
74
|
export type ResolvedSeo = {
|
|
@@ -69,6 +80,7 @@ export type ResolvedSeo = {
|
|
|
69
80
|
breadcrumbs?: PageMetaBreadcrumb[];
|
|
70
81
|
status?: number;
|
|
71
82
|
redirect?: PageMetaRedirect;
|
|
83
|
+
canonical?: string;
|
|
72
84
|
};
|
|
73
85
|
|
|
74
86
|
export function createPageMeta(meta: PageMeta): PageMeta {
|
|
@@ -101,6 +113,7 @@ export async function resolveMeta(
|
|
|
101
113
|
breadcrumbs,
|
|
102
114
|
status,
|
|
103
115
|
redirect,
|
|
116
|
+
canonical,
|
|
104
117
|
] = await Promise.all([
|
|
105
118
|
resolveValue(meta.title, ctx),
|
|
106
119
|
resolveValue(meta.description, ctx),
|
|
@@ -110,6 +123,7 @@ export async function resolveMeta(
|
|
|
110
123
|
resolveValue(meta.breadcrumbs, ctx),
|
|
111
124
|
resolveValue(meta.status, ctx),
|
|
112
125
|
resolveValue(meta.redirect, ctx),
|
|
126
|
+
resolveValue(meta.canonical, ctx),
|
|
113
127
|
]);
|
|
114
128
|
return {
|
|
115
129
|
title,
|
|
@@ -120,6 +134,7 @@ export async function resolveMeta(
|
|
|
120
134
|
breadcrumbs,
|
|
121
135
|
status,
|
|
122
136
|
redirect,
|
|
137
|
+
canonical,
|
|
123
138
|
};
|
|
124
139
|
}
|
|
125
140
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from "vitest";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import http, { type Server } from "node:http";
|
|
4
|
+
import type { AddressInfo } from "node:net";
|
|
5
|
+
import type { Request, Response } from "express";
|
|
6
|
+
import { initTRPC, TRPCError } from "@trpc/server";
|
|
7
|
+
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
|
8
|
+
import { isTRPCClientError } from "@trpc/client";
|
|
9
|
+
import { createRequestTrpcClient } from "./createRequestTrpcClient.js";
|
|
10
|
+
|
|
11
|
+
type Ctx = { req: Request; res: Response; user?: { id: string } };
|
|
12
|
+
const t = initTRPC.context<Ctx>().create();
|
|
13
|
+
|
|
14
|
+
// счётчик HTTP-вызовов: в local-режиме сервер не должен получать запросов
|
|
15
|
+
let httpHits = 0;
|
|
16
|
+
|
|
17
|
+
const appRouter = t.router({
|
|
18
|
+
entity: t.router({
|
|
19
|
+
task: t.router({
|
|
20
|
+
list: t.procedure
|
|
21
|
+
.input((v: unknown) => v as { limit: number })
|
|
22
|
+
.mutation(({ input, ctx }) => ({
|
|
23
|
+
limit: input.limit,
|
|
24
|
+
cookie: ctx.req.headers.cookie ?? null,
|
|
25
|
+
user: ctx.user?.id ?? null,
|
|
26
|
+
})),
|
|
27
|
+
}),
|
|
28
|
+
}),
|
|
29
|
+
secret: t.procedure.query(() => {
|
|
30
|
+
throw new TRPCError({ code: "UNAUTHORIZED", message: "login first" });
|
|
31
|
+
}),
|
|
32
|
+
now: t.procedure.query(() => new Date("2026-10-07T00:00:00.000Z")),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const createContext = ({ req, res }: { req: Request; res: Response }): Ctx => ({
|
|
36
|
+
req,
|
|
37
|
+
res,
|
|
38
|
+
user: (req as { user?: { id: string } }).user,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
let server: Server;
|
|
42
|
+
let port: number;
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
const app = express();
|
|
46
|
+
app.use((_req, _res, next) => {
|
|
47
|
+
httpHits++;
|
|
48
|
+
next();
|
|
49
|
+
});
|
|
50
|
+
app.use(
|
|
51
|
+
"/api/trpc",
|
|
52
|
+
createExpressMiddleware({ router: appRouter, createContext }),
|
|
53
|
+
);
|
|
54
|
+
server = http.createServer(app);
|
|
55
|
+
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
56
|
+
port = (server.address() as AddressInfo).port;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterAll(async () => {
|
|
60
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// SSR-запрос: cookie браузера и user, положенный passport'ом
|
|
64
|
+
const fakeReq = () =>
|
|
65
|
+
({
|
|
66
|
+
headers: { cookie: "quark.sid=abc" },
|
|
67
|
+
user: { id: "u1" },
|
|
68
|
+
}) as unknown as Request;
|
|
69
|
+
const fakeRes = () => ({}) as unknown as Response;
|
|
70
|
+
|
|
71
|
+
test("local: вложенная mutation вызывается без HTTP, с контекстом запроса", async () => {
|
|
72
|
+
const before = httpHits;
|
|
73
|
+
const client = createRequestTrpcClient({
|
|
74
|
+
req: fakeReq(),
|
|
75
|
+
res: fakeRes(),
|
|
76
|
+
port,
|
|
77
|
+
router: appRouter,
|
|
78
|
+
createContext,
|
|
79
|
+
});
|
|
80
|
+
const result = await (
|
|
81
|
+
client as unknown as {
|
|
82
|
+
entity: {
|
|
83
|
+
task: {
|
|
84
|
+
list: {
|
|
85
|
+
mutate: (i: { limit: number }) => Promise<unknown>;
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
).entity.task.list.mutate({ limit: 5 });
|
|
91
|
+
expect(result).toEqual({ limit: 5, cookie: "quark.sid=abc", user: "u1" });
|
|
92
|
+
expect(httpHits).toBe(before);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("local: TRPCError доходит как TRPCClientError с кодом, как по HTTP", async () => {
|
|
96
|
+
const client = createRequestTrpcClient({
|
|
97
|
+
req: fakeReq(),
|
|
98
|
+
res: fakeRes(),
|
|
99
|
+
port,
|
|
100
|
+
router: appRouter,
|
|
101
|
+
createContext,
|
|
102
|
+
});
|
|
103
|
+
const call = (
|
|
104
|
+
client as unknown as { secret: { query: () => Promise<unknown> } }
|
|
105
|
+
).secret.query();
|
|
106
|
+
await expect(call).rejects.toSatisfy((e: unknown) => {
|
|
107
|
+
return (
|
|
108
|
+
isTRPCClientError(e) &&
|
|
109
|
+
e.data?.code === "UNAUTHORIZED" &&
|
|
110
|
+
e.message === "login first"
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("local: Date приходит строкой — та же форма, что после JSON по HTTP", async () => {
|
|
116
|
+
const client = createRequestTrpcClient({
|
|
117
|
+
req: fakeReq(),
|
|
118
|
+
res: fakeRes(),
|
|
119
|
+
port,
|
|
120
|
+
router: appRouter,
|
|
121
|
+
createContext,
|
|
122
|
+
});
|
|
123
|
+
const now = await (
|
|
124
|
+
client as unknown as { now: { query: () => Promise<unknown> } }
|
|
125
|
+
).now.query();
|
|
126
|
+
expect(now).toBe("2026-10-07T00:00:00.000Z");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("http: без роутера идём на loopback и пробрасываем cookie", async () => {
|
|
130
|
+
const before = httpHits;
|
|
131
|
+
const client = createRequestTrpcClient({
|
|
132
|
+
req: fakeReq(),
|
|
133
|
+
res: fakeRes(),
|
|
134
|
+
port,
|
|
135
|
+
});
|
|
136
|
+
const result = await (
|
|
137
|
+
client as unknown as {
|
|
138
|
+
entity: {
|
|
139
|
+
task: {
|
|
140
|
+
list: {
|
|
141
|
+
mutate: (i: { limit: number }) => Promise<unknown>;
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
).entity.task.list.mutate({ limit: 1 });
|
|
147
|
+
// user по HTTP не передаётся: его на той стороне восстановит сессия
|
|
148
|
+
expect(result).toEqual({ limit: 1, cookie: "quark.sid=abc", user: null });
|
|
149
|
+
expect(httpHits).toBe(before + 1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("http: явный url главнее локального роутера (API в другом процессе)", async () => {
|
|
153
|
+
const before = httpHits;
|
|
154
|
+
const client = createRequestTrpcClient({
|
|
155
|
+
req: fakeReq(),
|
|
156
|
+
res: fakeRes(),
|
|
157
|
+
port: 1, // заведомо мёртвый loopback-порт: должен использоваться url
|
|
158
|
+
router: appRouter,
|
|
159
|
+
createContext,
|
|
160
|
+
url: `http://127.0.0.1:${port}/api/trpc`,
|
|
161
|
+
});
|
|
162
|
+
const now = await (
|
|
163
|
+
client as unknown as { now: { query: () => Promise<unknown> } }
|
|
164
|
+
).now.query();
|
|
165
|
+
expect(now).toBe("2026-10-07T00:00:00.000Z");
|
|
166
|
+
expect(httpHits).toBe(before + 1);
|
|
167
|
+
});
|
|
@@ -1,20 +1,75 @@
|
|
|
1
|
-
import { createTRPCClient, httpLink } from "@trpc/client";
|
|
1
|
+
import { createTRPCClient, httpLink, unstable_localLink } from "@trpc/client";
|
|
2
|
+
import type { Request, Response } from "express";
|
|
2
3
|
import { jsonSafeFetch, type TRPCClient } from "@quark-fw/store";
|
|
3
4
|
|
|
5
|
+
// Тип роутера берём из сигнатуры link'а: plugin-react не зависит от
|
|
6
|
+
// @trpc/server и не должен тянуть его ради одного типа
|
|
7
|
+
type LocalLinkOptions = Parameters<typeof unstable_localLink>[0];
|
|
8
|
+
export type SsrRouter = LocalLinkOptions["router"];
|
|
9
|
+
|
|
10
|
+
export type CreateRequestTrpcClientArgs = {
|
|
11
|
+
req: Request;
|
|
12
|
+
res: Response;
|
|
13
|
+
/** порт собственного HTTP-сервера — для loopback, когда роутера нет */
|
|
14
|
+
port: number;
|
|
15
|
+
/** роутер приложения, если он живёт в этом же процессе (quark.trpc.router) */
|
|
16
|
+
router?: SsrRouter;
|
|
17
|
+
/** контекст процедуры из запроса — тот же, что у /api/trpc */
|
|
18
|
+
createContext?: (args: { req: Request; res: Response }) => unknown;
|
|
19
|
+
/**
|
|
20
|
+
* Явный адрес API (SSR_TRPC_URL): tRPC-сервер в другом процессе или на
|
|
21
|
+
* другой машине. Главнее локального роутера.
|
|
22
|
+
*/
|
|
23
|
+
url?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
4
26
|
/**
|
|
5
|
-
* tRPC-клиент КОНКРЕТНОГО
|
|
6
|
-
*
|
|
7
|
-
*
|
|
27
|
+
* tRPC-клиент КОНКРЕТНОГО SSR-запроса. Три транспорта, по убыванию приоритета:
|
|
28
|
+
*
|
|
29
|
+
* 1. `url` — HTTP к внешнему API (другой процесс/машина), cookie пробрасывается.
|
|
30
|
+
* 2. `router` — вызов процедур in-process: без HTTP, сессии и passport на
|
|
31
|
+
* каждое чтение store; контекст собирается из этого же req/res, так что
|
|
32
|
+
* процедуры видят того же пользователя, что и по HTTP. Результат проходит
|
|
33
|
+
* JSON-раундтрип внутри link'а — Date приходит строкой, как по сети.
|
|
34
|
+
* 3. loopback — HTTP к собственному серверу (поведение до появления router).
|
|
35
|
+
*
|
|
36
|
+
* Ограничение in-process пути: ctx.res — это стримящийся SSR-ответ, и
|
|
37
|
+
* процедура, которая ставит заголовки, после записи shell получит
|
|
38
|
+
* ERR_HTTP_HEADERS_SENT. Клиент собирается здесь, а роутер объявляет
|
|
8
39
|
* приложение — тот же шов аугментации, что и в сторе.
|
|
9
40
|
*/
|
|
10
|
-
export function createRequestTrpcClient(
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
41
|
+
export function createRequestTrpcClient({
|
|
42
|
+
req,
|
|
43
|
+
res,
|
|
44
|
+
port,
|
|
45
|
+
router,
|
|
46
|
+
createContext,
|
|
47
|
+
url,
|
|
48
|
+
}: CreateRequestTrpcClientArgs): TRPCClient {
|
|
49
|
+
const cookie = req.headers.cookie;
|
|
50
|
+
|
|
51
|
+
if (!url && router && createContext) {
|
|
52
|
+
return createTRPCClient({
|
|
53
|
+
links: [
|
|
54
|
+
unstable_localLink({
|
|
55
|
+
router,
|
|
56
|
+
createContext: async () => createContext({ req, res }),
|
|
57
|
+
// как express-адаптер: наружу уходит форма ошибки, в лог —
|
|
58
|
+
// только то, что по HTTP было бы 500
|
|
59
|
+
onError: ({ error, path }) => {
|
|
60
|
+
if (error.code === "INTERNAL_SERVER_ERROR") {
|
|
61
|
+
console.error(`[react] ssr tRPC ${path}:`, error);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
],
|
|
66
|
+
}) as TRPCClient;
|
|
67
|
+
}
|
|
68
|
+
|
|
14
69
|
return createTRPCClient({
|
|
15
70
|
links: [
|
|
16
71
|
httpLink({
|
|
17
|
-
url: `http://127.0.0.1:${port}/api/trpc`,
|
|
72
|
+
url: url ?? `http://127.0.0.1:${port}/api/trpc`,
|
|
18
73
|
headers: cookie ? { cookie } : {},
|
|
19
74
|
fetch: jsonSafeFetch,
|
|
20
75
|
}),
|
|
@@ -3,6 +3,7 @@ import { Request, Response } from "express";
|
|
|
3
3
|
import { matchPath } from "react-router-dom";
|
|
4
4
|
import { UglyModule } from "../Router/UglyModule.js";
|
|
5
5
|
import removeSearchParamsFromUrl from "../utils/removeSearchParamsFromUrl.js";
|
|
6
|
+
import { parseSearch } from "../utils/parseSearch.js";
|
|
6
7
|
import { resolveMeta, type PageMeta } from "../seo/createPageMeta.js";
|
|
7
8
|
import {
|
|
8
9
|
buildLocalePath,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
import {
|
|
15
16
|
getReactAtoms,
|
|
16
17
|
getRequestOrigin,
|
|
18
|
+
getTrpcPlugin,
|
|
17
19
|
parseCookies,
|
|
18
20
|
serverErrorBody,
|
|
19
21
|
} from "./helpers.js";
|
|
@@ -62,12 +64,16 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
62
64
|
return;
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
// query нужен мете (пагинация) и ключу SEO-карты: одна и та же
|
|
68
|
+
// страница с разными ?page — разные title/canonical
|
|
69
|
+
const { search, query } = parseSearch(req.originalUrl);
|
|
65
70
|
const renderer = new rendererModule.Renderer({
|
|
66
71
|
url: req.originalUrl,
|
|
67
72
|
manifest,
|
|
68
73
|
locale,
|
|
69
74
|
basename,
|
|
70
75
|
strippedPathname,
|
|
76
|
+
search,
|
|
71
77
|
});
|
|
72
78
|
|
|
73
79
|
const page = rendererModule.hasUglyPage(strippedPathname) as
|
|
@@ -76,10 +82,17 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
76
82
|
const registerModule = createPreloadTagWriter(manifest, res);
|
|
77
83
|
|
|
78
84
|
const reactAtoms = getReactAtoms();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
// роутер читается на каждый запрос: приложение отдаёт его в
|
|
86
|
+
// setRouter после quark.ready(), конкурентно со стартом сервера
|
|
87
|
+
const trpc = getTrpcPlugin();
|
|
88
|
+
const trpcClient = createRequestTrpcClient({
|
|
89
|
+
req,
|
|
90
|
+
res,
|
|
91
|
+
port: reactAtoms?.port?.value ?? 3000,
|
|
92
|
+
router: trpc?.router,
|
|
93
|
+
createContext: trpc?.createContext,
|
|
94
|
+
url: process.env.SSR_TRPC_URL,
|
|
95
|
+
});
|
|
83
96
|
const { store, releaseCacheWrites } = createStreamingStore(
|
|
84
97
|
trpcClient,
|
|
85
98
|
res,
|
|
@@ -108,6 +121,7 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
108
121
|
params: (routeProps?.params ?? {}) as Record<string, string>,
|
|
109
122
|
locale: locale || undefined,
|
|
110
123
|
origin,
|
|
124
|
+
query,
|
|
111
125
|
});
|
|
112
126
|
|
|
113
127
|
// редирект из меты: страница переехала или схлопнута — отвечаем
|
|
@@ -122,13 +136,21 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
122
136
|
|
|
123
137
|
res.status(seo.status ?? (page ? 200 : 404));
|
|
124
138
|
|
|
125
|
-
// hreflang-кластер: каждая локаль + x-default на
|
|
139
|
+
// hreflang-кластер: каждая локаль + x-default на дефолтную.
|
|
140
|
+
// Относительный canonical (например /tbilisi?page=2) задаёт путь
|
|
141
|
+
// кластера — иначе страница 2 ссылалась бы на страницу 1 других
|
|
142
|
+
// локалей и кластер переставал быть взаимным
|
|
143
|
+
const alternatePath =
|
|
144
|
+
seo.canonical && seo.canonical.startsWith("/")
|
|
145
|
+
? seo.canonical
|
|
146
|
+
: pathname;
|
|
126
147
|
const alternates =
|
|
127
148
|
i18nConfig && usesLocalePrefix(i18nConfig)
|
|
128
149
|
? [
|
|
129
150
|
...i18nConfig.locales.map((l) => ({
|
|
130
151
|
hreflang: l,
|
|
131
|
-
href:
|
|
152
|
+
href:
|
|
153
|
+
origin + buildLocalePath(l, alternatePath),
|
|
132
154
|
})),
|
|
133
155
|
{
|
|
134
156
|
hreflang: "x-default",
|
|
@@ -136,7 +158,7 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
136
158
|
origin +
|
|
137
159
|
buildLocalePath(
|
|
138
160
|
i18nConfig.defaultLocale,
|
|
139
|
-
|
|
161
|
+
alternatePath,
|
|
140
162
|
),
|
|
141
163
|
},
|
|
142
164
|
]
|
|
@@ -148,7 +170,8 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
|
|
|
148
170
|
store,
|
|
149
171
|
trpcClient,
|
|
150
172
|
seo,
|
|
151
|
-
|
|
173
|
+
// ключ — путь + search, как его увидит SeoProvider через useLocation()
|
|
174
|
+
seoObject: { [pathname + search]: seo },
|
|
152
175
|
favicon: reactAtoms?.favicon?.value,
|
|
153
176
|
origin,
|
|
154
177
|
alternates,
|
package/src/ssr/helpers.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Хелперы SSR-мидлвары.
|
|
2
|
-
import type { Request } from "express";
|
|
2
|
+
import type { Request, Response } from "express";
|
|
3
|
+
import type { SsrRouter } from "./createRequestTrpcClient.js";
|
|
3
4
|
|
|
4
5
|
export const isProd = process.env.NODE_ENV === "production";
|
|
5
6
|
|
|
@@ -49,6 +50,19 @@ export type ReactAtoms = {
|
|
|
49
50
|
export const getReactAtoms = (): ReactAtoms | undefined =>
|
|
50
51
|
(globalThis as { quark?: { react?: ReactAtoms } }).quark?.react;
|
|
51
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Поверхность plugin-trpc для in-process вызовов из SSR. Читается
|
|
55
|
+
* структурно: plugin-react не зависит от plugin-trpc и работает без него
|
|
56
|
+
* (тогда SSR ходит по HTTP).
|
|
57
|
+
*/
|
|
58
|
+
export type TrpcPlugin = {
|
|
59
|
+
router?: SsrRouter;
|
|
60
|
+
createContext?: (args: { req: Request; res: Response }) => unknown;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const getTrpcPlugin = (): TrpcPlugin | undefined =>
|
|
64
|
+
(globalThis as { quark?: { trpc?: TrpcPlugin } }).quark?.trpc;
|
|
65
|
+
|
|
52
66
|
/**
|
|
53
67
|
* Абсолютный origin запроса для canonical/hreflang/JSON-LD.
|
|
54
68
|
*
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { parseSearch, seoKey } from "./parseSearch.js";
|
|
3
|
+
|
|
4
|
+
test("parseSearch: первое значение параметра, нормализованный search", () => {
|
|
5
|
+
expect(parseSearch("/ru/tbilisi?page=2&rooms=3&rooms=4#top")).toEqual({
|
|
6
|
+
search: "?page=2&rooms=3&rooms=4",
|
|
7
|
+
query: { page: "2", rooms: "3" },
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("parseSearch: пустой и одиночный ? дают пустой search", () => {
|
|
12
|
+
expect(parseSearch("/ru/tbilisi")).toEqual({ search: "", query: {} });
|
|
13
|
+
expect(parseSearch("?")).toEqual({ search: "", query: {} });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("seoKey совпадает для SSR-URL и useLocation()", () => {
|
|
17
|
+
expect(seoKey("/tbilisi", "?page=2")).toBe("/tbilisi?page=2");
|
|
18
|
+
expect(seoKey("/tbilisi", "")).toBe("/tbilisi");
|
|
19
|
+
expect(seoKey("/tbilisi", "?")).toBe("/tbilisi");
|
|
20
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Разбор query-строки URL для меты и ключа SEO-карты. Без Node-зависимостей:
|
|
2
|
+
// модуль нужен и SSR-мидлваре, и SeoProvider в браузере.
|
|
3
|
+
|
|
4
|
+
export type ParsedSearch = {
|
|
5
|
+
/** нормализованный search: "?a=1" или "" (одиночный "?" — тоже "") */
|
|
6
|
+
search: string;
|
|
7
|
+
/** первое значение каждого параметра */
|
|
8
|
+
query: Record<string, string>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function parseSearch(urlOrSearch: string): ParsedSearch {
|
|
12
|
+
const index = urlOrSearch.indexOf("?");
|
|
13
|
+
const raw = index === -1 ? "" : urlOrSearch.slice(index + 1);
|
|
14
|
+
// hash в URL не относится к запросу
|
|
15
|
+
const withoutHash = raw.split("#")[0] ?? "";
|
|
16
|
+
const params = new URLSearchParams(withoutHash);
|
|
17
|
+
const query: Record<string, string> = {};
|
|
18
|
+
for (const [key, value] of params) {
|
|
19
|
+
if (!(key in query)) query[key] = value;
|
|
20
|
+
}
|
|
21
|
+
const search = params.toString();
|
|
22
|
+
return { search: search ? `?${search}` : "", query };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Ключ SEO-карты: путь без basename + нормализованный search. */
|
|
26
|
+
export const seoKey = (pathname: string, search: string) =>
|
|
27
|
+
pathname + parseSearch(search).search;
|