@quark-fw/plugin-react 0.1.24 → 0.1.26

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quark-fw/plugin-react",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
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.24",
26
- "@quark-fw/plugin": "0.1.24",
27
- "@trpc/client": "^11.0.0",
25
+ "@quark-fw/atom": "0.1.26",
26
+ "@quark-fw/plugin": "0.1.26",
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.24",
40
- "@quark-fw/store": "0.1.24",
39
+ "@quark-fw/core": "0.1.26",
40
+ "@quark-fw/store": "0.1.26",
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"
@@ -0,0 +1,16 @@
1
+ import { expect, test } from "vitest";
2
+ import { buildLocalePath } from "./config.js";
3
+
4
+ // Корень локали — без завершающего слэша: так его рендерит react-router в
5
+ // ссылках (href="/ru") и так его перечисляет sitemap приложения. Форма со
6
+ // слэшем превращалась у Google в дубликат с другим canonical.
7
+ test("buildLocalePath: корень локали без слэша", () => {
8
+ expect(buildLocalePath("ru", "/")).toBe("/ru");
9
+ expect(buildLocalePath("ru", "")).toBe("/ru");
10
+ });
11
+
12
+ test("buildLocalePath: вложенные пути с префиксом и без", () => {
13
+ expect(buildLocalePath("en", "/tbilisi")).toBe("/en/tbilisi");
14
+ expect(buildLocalePath("en", "tbilisi")).toBe("/en/tbilisi");
15
+ expect(buildLocalePath("ka", "/tbilisi?page=2")).toBe("/ka/tbilisi?page=2");
16
+ });
@@ -79,6 +79,9 @@ export function parseLocalePath(
79
79
  }
80
80
 
81
81
  export function buildLocalePath(locale: string, strippedPath: string): string {
82
+ // корень локали — без завершающего слэша: так его рендерит react-router
83
+ // в ссылках и перечисляет sitemap; /ru/ Google считал дубликатом /ru
84
+ if (strippedPath === "/" || strippedPath === "") return `/${locale}`;
82
85
  return `/${locale}${strippedPath.startsWith("/") ? strippedPath : `/${strippedPath}`}`;
83
86
  }
84
87
 
@@ -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-клиент КОНКРЕТНОГО запроса: SSR ходит к собственному серверу по
6
- * HTTP и пробрасывает cookie, чтобы страница рендерилась от имени
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
- port: number,
12
- cookie: string | undefined,
13
- ): TRPCClient {
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
  }),
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  getReactAtoms,
17
17
  getRequestOrigin,
18
+ getTrpcPlugin,
18
19
  parseCookies,
19
20
  serverErrorBody,
20
21
  } from "./helpers.js";
@@ -23,6 +24,7 @@ import { createRendererLoader } from "./createRendererLoader.js";
23
24
  import { createPreloadTagWriter } from "./createPreloadTagWriter.js";
24
25
  import { createRequestTrpcClient } from "./createRequestTrpcClient.js";
25
26
  import { createStreamingStore } from "./createStreamingStore.js";
27
+ import { stripTrailingSlash } from "../utils/stripTrailingSlash.js";
26
28
 
27
29
  export type GetRenderMiddlewareArgs = {
28
30
  /** dev: модули грузятся через vite; в prod — из собранного бандла */
@@ -42,6 +44,21 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
42
44
  const i18nConfig = (rendererModule as { i18nConfig?: I18nConfig })
43
45
  .i18nConfig;
44
46
  const urlWithoutSearch = removeSearchParamsFromUrl(req.originalUrl);
47
+
48
+ // одна каноническая форма пути — без завершающего слэша: /ru/ и
49
+ // /ru/tbilisi/ отдавали 200 со своим canonical и дублировали страницы
50
+ const normalized = stripTrailingSlash(urlWithoutSearch);
51
+ if (normalized) {
52
+ const searchIndex = req.originalUrl.indexOf("?");
53
+ res.redirect(
54
+ 301,
55
+ normalized +
56
+ (searchIndex === -1
57
+ ? ""
58
+ : req.originalUrl.slice(searchIndex)),
59
+ );
60
+ return;
61
+ }
45
62
  const { locale, strippedPathname, basename, redirect } =
46
63
  parseLocalePath(urlWithoutSearch, i18nConfig);
47
64
 
@@ -81,10 +98,17 @@ export default function getRenderMiddleware({ vite }: GetRenderMiddlewareArgs) {
81
98
  const registerModule = createPreloadTagWriter(manifest, res);
82
99
 
83
100
  const reactAtoms = getReactAtoms();
84
- const trpcClient = createRequestTrpcClient(
85
- reactAtoms?.port?.value ?? 3000,
86
- req.headers.cookie,
87
- );
101
+ // роутер читается на каждый запрос: приложение отдаёт его в
102
+ // setRouter после quark.ready(), конкурентно со стартом сервера
103
+ const trpc = getTrpcPlugin();
104
+ const trpcClient = createRequestTrpcClient({
105
+ req,
106
+ res,
107
+ port: reactAtoms?.port?.value ?? 3000,
108
+ router: trpc?.router,
109
+ createContext: trpc?.createContext,
110
+ url: process.env.SSR_TRPC_URL,
111
+ });
88
112
  const { store, releaseCacheWrites } = createStreamingStore(
89
113
  trpcClient,
90
114
  res,
@@ -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,14 @@
1
+ import { expect, test } from "vitest";
2
+ import { stripTrailingSlash } from "./stripTrailingSlash.js";
3
+
4
+ test("stripTrailingSlash: путь без слэша и корень не меняются", () => {
5
+ expect(stripTrailingSlash("/")).toBeNull();
6
+ expect(stripTrailingSlash("/ru")).toBeNull();
7
+ expect(stripTrailingSlash("/ru/tbilisi")).toBeNull();
8
+ });
9
+
10
+ test("stripTrailingSlash: завершающие слэши срезаются", () => {
11
+ expect(stripTrailingSlash("/ru/")).toBe("/ru");
12
+ expect(stripTrailingSlash("/ru/tbilisi/")).toBe("/ru/tbilisi");
13
+ expect(stripTrailingSlash("/ru/tbilisi//")).toBe("/ru/tbilisi");
14
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Путь без завершающих слэшей, если они были; null — если менять нечего.
3
+ * Корень "/" остаётся как есть. Нужен SSR-мидлваре: /ru/ и /ru/tbilisi/
4
+ * рендерились как отдельные страницы со своим canonical, и Google считал
5
+ * их дубликатами.
6
+ */
7
+ export function stripTrailingSlash(pathname: string): string | null {
8
+ if (pathname.length <= 1 || !pathname.endsWith("/")) return null;
9
+ return pathname.replace(/\/+$/, "") || "/";
10
+ }