@webnumseoagent/next 0.1.11 → 0.1.14

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/bin/cli.mjs CHANGED
@@ -97,7 +97,7 @@ async function init() {
97
97
  ok("Пакет @webnumseoagent/next установлен — импорт из пакета.");
98
98
  } else {
99
99
  const vendor = path.join(root, "seoagent");
100
- for (const f of ["client.ts", "jsonld.tsx", "sitemap-xsl.ts", "sitemap-build.ts"]) {
100
+ for (const f of ["client.ts", "jsonld.tsx", "sitemap-xsl.ts", "sitemap-build.ts", "redirects.ts"]) {
101
101
  const dest = path.join(vendor, f);
102
102
  if (!(await exists(dest))) {
103
103
  await fs.mkdir(vendor, { recursive: true });
@@ -224,6 +224,65 @@ async function init() {
224
224
  `import { revalidateTag } from "next/cache";\n\nexport async function POST(req${ext === "ts" ? ": Request" : ""}) {\n if (req.headers.get("x-revalidate-secret") !== process.env.SEOAGENT_REVALIDATE_SECRET) {\n return new Response("unauthorized", { status: 401 });\n }\n revalidateTag(\`seo-\${process.env.SEOAGENT_SITE_ID}\`);\n return Response.json({ ok: true });\n}\n`,
225
225
  );
226
226
 
227
+ // 4.5 middleware (переадресации). Next разрешает ОДИН middleware, а авто-слияние чужого
228
+ // небезопасно → создаём свой, только если своего нет; иначе печатаем ручную инструкцию.
229
+ {
230
+ const MW_MATCHER = '"/((?!api|_next/static|_next/image|favicon.ico|.*\\\\..*).*)"';
231
+ const mwBase = appDir === "src/app" ? "src" : "."; // middleware рядом с app/
232
+ const mwDirAbs = path.join(root, mwBase === "." ? "" : mwBase);
233
+ const existing = [];
234
+ for (const b of [".", "src"]) {
235
+ for (const f of ["middleware.ts", "middleware.js"]) {
236
+ const rel = b === "." ? f : `${b}/${f}`;
237
+ if (await exists(path.join(root, rel))) existing.push(rel);
238
+ }
239
+ }
240
+ const redirImport = pkgMode
241
+ ? "@webnumseoagent/next/redirects"
242
+ : (() => {
243
+ let rel = path.relative(mwDirAbs, path.join(root, "seoagent")).split(path.sep).join("/");
244
+ if (!rel.startsWith(".")) rel = "./" + rel;
245
+ return `${rel}/redirects`;
246
+ })();
247
+
248
+ if (existing.length) {
249
+ log(`\n \x1b[1mmiddleware уже есть (${existing.join(", ")})\x1b[0m — добавь переадресации ВРУЧНУЮ (Next разрешает один middleware):`);
250
+ log(` \x1b[36mimport { seoRedirects } from "${redirImport}";\x1b[0m`);
251
+ log(` 1) первым в функции middleware: \x1b[36mconst r = await seoRedirects(request); if (r) return r;\x1b[0m`);
252
+ log(` 2) добавь в config.matcher: \x1b[36m${MW_MATCHER}\x1b[0m`);
253
+ log(` 3) если middleware делает auth — оставь свою логику scoped (напр. if(!path.startsWith("/admin")) return NextResponse.next()).`);
254
+ log(` \x1b[1mAuth-чувствительно: проверь git diff и вход перед деплоем.\x1b[0m`);
255
+ } else {
256
+ const mwPath = path.join(mwDirAbs, `middleware.${ext}`);
257
+ const body =
258
+ ext === "ts"
259
+ ? `import { NextResponse, type NextRequest } from "next/server";\nimport { seoRedirects } from "${redirImport}";\n\nexport async function middleware(request: NextRequest) {\n const redirect = await seoRedirects(request);\n if (redirect) return redirect;\n return NextResponse.next();\n}\n\nexport const config = { matcher: [${MW_MATCHER}] };\n`
260
+ : `import { NextResponse } from "next/server";\nimport { seoRedirects } from "${redirImport}";\n\nexport async function middleware(request) {\n const redirect = await seoRedirects(request);\n if (redirect) return redirect;\n return NextResponse.next();\n}\n\nexport const config = { matcher: [${MW_MATCHER}] };\n`;
261
+ await ensureFile(mwPath, body);
262
+ }
263
+ }
264
+
265
+ // 4.6 Мониторинг 404: роут-приёмник на сайте (форвардит хит в платформу с СЕРВЕРНЫМ
266
+ // токеном) + not-found-репортер (клиентский маячок с путём 404). Репортер отдаём сырым
267
+ // исходником (client-компонент из dist пакета не работает — tsc ставит "use strict" первой).
268
+ {
269
+ const reqType = ext === "ts" ? ": Request" : "";
270
+ const fwd = `export async function POST(req${reqType}) {\n const origin = req.headers.get("origin"), host = req.headers.get("host");\n if (origin && host) { try { if (new URL(origin).host !== host) return new Response(null, { status: 204 }); } catch { return new Response(null, { status: 204 }); } }\n const API = process.env.SEOAGENT_API_BASE, SITE = process.env.SEOAGENT_SITE_ID, TOKEN = process.env.SEOAGENT_TOKEN;\n if (!API || !SITE || !TOKEN) return new Response(null, { status: 204 });\n let path = "";\n try { const b = await req.json(); if (typeof b?.path === "string") path = b.path; } catch {}\n if (!path) return new Response(null, { status: 204 });\n try { await fetch(\`\${API}/api/seo/\${SITE}/not-found\`, { method: "POST", headers: { "content-type": "application/json", "x-seo-agent-key": TOKEN }, body: JSON.stringify({ path }), signal: AbortSignal.timeout(5000) }); } catch {}\n return new Response(null, { status: 204 });\n}\n`;
271
+ await ensureFile(path.join(root, appDir, `api/seoagent-404/route.${ext}`), fwd);
272
+
273
+ const reporter = `"use client";\n\nimport Link from "next/link";\nimport { usePathname } from "next/navigation";\nimport { useEffect, useRef } from "react";\n\n// Страница 404 + репортер для «Мониторинг 404» (SEO Agent).\nexport default function NotFound() {\n const pathname = usePathname();\n const lastSent = useRef(null);\n useEffect(() => {\n const p = pathname || window.location.pathname;\n if (lastSent.current === p) return;\n lastSent.current = p;\n try {\n const body = JSON.stringify({ path: p });\n if (navigator.sendBeacon) navigator.sendBeacon("/api/seoagent-404", new Blob([body], { type: "application/json" }));\n else fetch("/api/seoagent-404", { method: "POST", headers: { "content-type": "application/json" }, body, keepalive: true }).catch(() => {});\n } catch {}\n }, [pathname]);\n return (\n <main style={{ minHeight: "60vh", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "1rem", padding: "2rem", textAlign: "center" }}>\n <h1 style={{ fontSize: "3rem", margin: 0 }}>404</h1>\n <p style={{ color: "#64748b", margin: 0 }}>Страница не найдена.</p>\n <Link href="/" style={{ color: "#2563eb" }}>На главную</Link>\n </main>\n );\n}\n`;
274
+ const nfExt = ext === "ts" ? "tsx" : "js";
275
+ const hasNotFound = await anyExists([
276
+ `${appDir}/not-found.js`, `${appDir}/not-found.jsx`, `${appDir}/not-found.ts`, `${appDir}/not-found.tsx`,
277
+ ]);
278
+ if (hasNotFound) {
279
+ log(`\n \x1b[1mnot-found уже есть\x1b[0m — добавь репортер 404 (Мониторинг 404) в свой not-found:`);
280
+ log(` \x1b[36m"use client"; ... useEffect(() => { navigator.sendBeacon?.("/api/seoagent-404", new Blob([JSON.stringify({ path: location.pathname })], { type: "application/json" })); }, []);\x1b[0m`);
281
+ } else {
282
+ await ensureFile(path.join(root, appDir, `not-found.${nfExt}`), reporter);
283
+ }
284
+ }
285
+
227
286
  // 5. .env.local
228
287
  const envPath = path.join(root, ".env.local");
229
288
  const cur = (await exists(envPath)) ? await fs.readFile(envPath, "utf8") : "";
package/client.ts CHANGED
@@ -24,6 +24,7 @@ interface SeoFields {
24
24
  og?: { title?: string; description?: string; image?: string };
25
25
  twitter?: { card?: string; image?: string };
26
26
  jsonLd?: unknown[];
27
+ verification?: Record<string, string>; // проверочные мета-теги: metaName → content
27
28
  }
28
29
 
29
30
  async function fetchJson<T>(path: string, revalidate = 300): Promise<T | null> {
@@ -75,6 +76,11 @@ function mergeIntoMetadata(fallback: Metadata, f: SeoFields): Metadata {
75
76
  ...(f.twitter.image ? { images: [f.twitter.image] } : {}),
76
77
  };
77
78
  }
79
+ // Проверочные мета-теги веб-мастерских → <meta name content> через metadata.other.
80
+ // Мержим, не затирая существующий other сайта.
81
+ if (f.verification && typeof f.verification === "object") {
82
+ m.other = { ...(m.other ?? {}), ...f.verification };
83
+ }
78
84
  return m;
79
85
  }
80
86
 
package/dist/client.js CHANGED
@@ -75,6 +75,11 @@ function mergeIntoMetadata(fallback, f) {
75
75
  ...(f.twitter.image ? { images: [f.twitter.image] } : {}),
76
76
  };
77
77
  }
78
+ // Проверочные мета-теги веб-мастерских → <meta name content> через metadata.other.
79
+ // Мержим, не затирая существующий other сайта.
80
+ if (f.verification && typeof f.verification === "object") {
81
+ m.other = { ...(m.other ?? {}), ...f.verification };
82
+ }
78
83
  return m;
79
84
  }
80
85
  // В generateMetadata: const md = await seoMeta({ route, locale, fallback: compiled });
@@ -0,0 +1,2 @@
1
+ import { NextResponse, type NextRequest } from "next/server";
2
+ export declare function seoRedirects(request: NextRequest): Promise<NextResponse | null>;
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.seoRedirects = seoRedirects;
4
+ // Рантайм-переадресации для Next.js middleware (Edge). Тянет активные правила с
5
+ // платформы и применяет к входящему запросу. Отдельный сабпас "@webnumseoagent/next/redirects",
6
+ // чтобы Edge-код не попадал в RSC-бандлы.
7
+ //
8
+ // В Edge middleware Next Data Cache (next:{revalidate,tags}) НЕ работает и revalidateTag
9
+ // сюда не достаёт — поэтому кэшируем правила модульным TTL (переживает жизнь изолята).
10
+ // Правки на платформе применяются в пределах TTL (~минута), не мгновенно.
11
+ //
12
+ // FAIL-SAFE: при любой ошибке / отсутствии env возвращаем null — сайт работает как обычно.
13
+ const server_1 = require("next/server");
14
+ const API = process.env.SEOAGENT_API_BASE ?? "";
15
+ const SITE = process.env.SEOAGENT_SITE_ID ?? "";
16
+ const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
17
+ const TTL_OK = 60000; // успех — правила живут минуту
18
+ const TTL_FAIL = 10000; // ошибка — быстрее повторить, но не долбить
19
+ const FETCH_TIMEOUT = 2000; // middleware на горячем пути каждого запроса — таймаут короткий
20
+ const MAX_PATTERN = 200;
21
+ const MAX_PATH = 2000;
22
+ const is3xx = (c) => c === 301 || c === 302 || c === 307;
23
+ function normPath(p) {
24
+ let s = p;
25
+ try {
26
+ s = decodeURIComponent(p);
27
+ }
28
+ catch {
29
+ /* битый % — оставляем как есть */
30
+ }
31
+ if (s.length > MAX_PATH)
32
+ s = s.slice(0, MAX_PATH);
33
+ if (s.length > 1)
34
+ s = s.replace(/\/+$/, "") || "/";
35
+ return s;
36
+ }
37
+ function normSource(v) {
38
+ let s = (v ?? "").trim();
39
+ if (!s)
40
+ return s;
41
+ if (/^https?:\/\//i.test(s))
42
+ return s.replace(/\/+$/, "");
43
+ if (!s.startsWith("/"))
44
+ s = "/" + s;
45
+ return s.length > 1 ? s.replace(/\/+$/, "") : s;
46
+ }
47
+ function compile(rules) {
48
+ const out = [];
49
+ for (const r of rules) {
50
+ if (!r || r.active === false)
51
+ continue;
52
+ const code = Number(r.code);
53
+ if (is3xx(code) && !r.destination)
54
+ continue; // 3xx без назначения бессмысленно
55
+ if (![301, 302, 307, 410, 451].includes(code))
56
+ continue;
57
+ const tests = [];
58
+ for (const src of r.sources ?? []) {
59
+ const raw = (src?.value ?? "").trim();
60
+ if (!raw || raw.length > MAX_PATTERN)
61
+ continue;
62
+ const ci = src.ignoreCase !== false; // по умолчанию регистронезависимо
63
+ if (src.type === "regex") {
64
+ try {
65
+ const re = new RegExp(raw, ci ? "i" : ""); // компилируем ОДИН раз
66
+ tests.push((p) => re.test(p));
67
+ }
68
+ catch {
69
+ /* битый regex — пропускаем только этот источник */
70
+ }
71
+ }
72
+ else {
73
+ // exact/start — пути (нормализуем как путь); contains/end — под-строка/суффикс
74
+ // (берём как есть, только trim), иначе навязанный '/' ломает совпадение (".html").
75
+ const norm = src.type === "exact" || src.type === "start" ? normSource(raw) : raw.trim();
76
+ const v = ci ? norm.toLowerCase() : norm;
77
+ const f = (p) => (ci ? p.toLowerCase() : p);
78
+ if (src.type === "exact")
79
+ tests.push((p) => f(p) === v);
80
+ else if (src.type === "contains")
81
+ tests.push((p) => f(p).includes(v));
82
+ else if (src.type === "start")
83
+ tests.push((p) => f(p).startsWith(v));
84
+ else if (src.type === "end")
85
+ tests.push((p) => f(p).endsWith(v));
86
+ }
87
+ }
88
+ if (tests.length)
89
+ out.push({ test: (p) => tests.some((t) => t(p)), destination: r.destination, code });
90
+ }
91
+ return out;
92
+ }
93
+ let cache = null;
94
+ let inflight = null;
95
+ async function loadRules() {
96
+ if (!API || !SITE || !TOKEN)
97
+ return null;
98
+ try {
99
+ const r = await fetch(`${API}/api/seo/${SITE}/redirects`, {
100
+ headers: { "x-seo-agent-key": TOKEN },
101
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
102
+ // без next:{revalidate,tags} — в Edge middleware не действуют
103
+ });
104
+ if (!r.ok)
105
+ return null;
106
+ const body = (await r.json());
107
+ const raw = Array.isArray(body) ? body : (body?.rules ?? []);
108
+ return compile(Array.isArray(raw) ? raw : []);
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
114
+ async function getRules() {
115
+ const now = Date.now();
116
+ if (cache && now - cache.at < cache.ttl)
117
+ return cache.rules;
118
+ if (inflight)
119
+ return inflight; // дедуп конкурентных промахов на холодном изоляте
120
+ inflight = loadRules()
121
+ .then((rules) => {
122
+ cache = { at: Date.now(), ttl: rules === null ? TTL_FAIL : TTL_OK, rules };
123
+ inflight = null;
124
+ return rules;
125
+ })
126
+ .catch(() => {
127
+ inflight = null;
128
+ return cache?.rules ?? null;
129
+ });
130
+ return inflight;
131
+ }
132
+ // Вызывать ПЕРВЫМ в middleware: `const r = await seoRedirects(request); if (r) return r;`
133
+ async function seoRedirects(request) {
134
+ try {
135
+ const rules = await getRules();
136
+ if (!rules || rules.length === 0)
137
+ return null;
138
+ const path = normPath(request.nextUrl.pathname);
139
+ for (const rule of rules) {
140
+ if (!rule.test(path))
141
+ continue;
142
+ if (rule.code === 410 || rule.code === 451) {
143
+ const msg = rule.code === 410 ? "410 Gone" : "451 Unavailable For Legal Reasons";
144
+ return new server_1.NextResponse(msg, { status: rule.code, headers: { "content-type": "text/plain; charset=utf-8" } });
145
+ }
146
+ const abs = /^https?:\/\//i.test(rule.destination);
147
+ const target = abs ? new URL(rule.destination) : new URL(rule.destination, request.url);
148
+ // Защита от петли: назначение = текущий путь того же origin — пропускаем.
149
+ if (target.origin === request.nextUrl.origin && normPath(target.pathname) === path)
150
+ continue;
151
+ // Сохраняем входящий query, если у назначения своего нет.
152
+ if (!target.search && request.nextUrl.search)
153
+ target.search = request.nextUrl.search;
154
+ return server_1.NextResponse.redirect(target, rule.code);
155
+ }
156
+ return null;
157
+ }
158
+ catch {
159
+ return null; // любая ошибка → сайт продолжает работать
160
+ }
161
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webnumseoagent/next",
3
- "version": "0.1.11",
3
+ "version": "0.1.14",
4
4
  "description": "Runtime SEO adapter + installer for Next.js App Router sites (SEO Agent platform)",
5
5
  "main": "dist/client.js",
6
6
  "types": "dist/client.d.ts",
@@ -15,6 +15,10 @@
15
15
  "./jsonld": {
16
16
  "types": "./dist/jsonld.d.ts",
17
17
  "default": "./dist/jsonld.js"
18
+ },
19
+ "./redirects": {
20
+ "types": "./dist/redirects.d.ts",
21
+ "default": "./dist/redirects.js"
18
22
  }
19
23
  },
20
24
  "files": [
@@ -23,6 +27,7 @@
23
27
  "jsonld.tsx",
24
28
  "sitemap-xsl.ts",
25
29
  "sitemap-build.ts",
30
+ "redirects.ts",
26
31
  "bin",
27
32
  "README.md"
28
33
  ],
package/redirects.ts ADDED
@@ -0,0 +1,166 @@
1
+ // Рантайм-переадресации для Next.js middleware (Edge). Тянет активные правила с
2
+ // платформы и применяет к входящему запросу. Отдельный сабпас "@webnumseoagent/next/redirects",
3
+ // чтобы Edge-код не попадал в RSC-бандлы.
4
+ //
5
+ // В Edge middleware Next Data Cache (next:{revalidate,tags}) НЕ работает и revalidateTag
6
+ // сюда не достаёт — поэтому кэшируем правила модульным TTL (переживает жизнь изолята).
7
+ // Правки на платформе применяются в пределах TTL (~минута), не мгновенно.
8
+ //
9
+ // FAIL-SAFE: при любой ошибке / отсутствии env возвращаем null — сайт работает как обычно.
10
+ import { NextResponse, type NextRequest } from "next/server";
11
+
12
+ const API = process.env.SEOAGENT_API_BASE ?? "";
13
+ const SITE = process.env.SEOAGENT_SITE_ID ?? "";
14
+ const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
15
+
16
+ type MatchType = "exact" | "contains" | "start" | "end" | "regex";
17
+ interface Source {
18
+ value: string;
19
+ type: MatchType;
20
+ ignoreCase?: boolean;
21
+ }
22
+ interface RawRule {
23
+ id?: string;
24
+ sources: Source[];
25
+ destination: string;
26
+ code: number;
27
+ active?: boolean;
28
+ }
29
+ interface CompiledRule {
30
+ test: (path: string) => boolean;
31
+ destination: string;
32
+ code: number;
33
+ }
34
+
35
+ const TTL_OK = 60_000; // успех — правила живут минуту
36
+ const TTL_FAIL = 10_000; // ошибка — быстрее повторить, но не долбить
37
+ const FETCH_TIMEOUT = 2_000; // middleware на горячем пути каждого запроса — таймаут короткий
38
+ const MAX_PATTERN = 200;
39
+ const MAX_PATH = 2_000;
40
+
41
+ const is3xx = (c: number) => c === 301 || c === 302 || c === 307;
42
+
43
+ function normPath(p: string): string {
44
+ let s = p;
45
+ try {
46
+ s = decodeURIComponent(p);
47
+ } catch {
48
+ /* битый % — оставляем как есть */
49
+ }
50
+ if (s.length > MAX_PATH) s = s.slice(0, MAX_PATH);
51
+ if (s.length > 1) s = s.replace(/\/+$/, "") || "/";
52
+ return s;
53
+ }
54
+
55
+ function normSource(v: string): string {
56
+ let s = (v ?? "").trim();
57
+ if (!s) return s;
58
+ if (/^https?:\/\//i.test(s)) return s.replace(/\/+$/, "");
59
+ if (!s.startsWith("/")) s = "/" + s;
60
+ return s.length > 1 ? s.replace(/\/+$/, "") : s;
61
+ }
62
+
63
+ function compile(rules: RawRule[]): CompiledRule[] {
64
+ const out: CompiledRule[] = [];
65
+ for (const r of rules) {
66
+ if (!r || r.active === false) continue;
67
+ const code = Number(r.code);
68
+ if (is3xx(code) && !r.destination) continue; // 3xx без назначения бессмысленно
69
+ if (![301, 302, 307, 410, 451].includes(code)) continue;
70
+
71
+ const tests: Array<(p: string) => boolean> = [];
72
+ for (const src of r.sources ?? []) {
73
+ const raw = (src?.value ?? "").trim();
74
+ if (!raw || raw.length > MAX_PATTERN) continue;
75
+ const ci = src.ignoreCase !== false; // по умолчанию регистронезависимо
76
+ if (src.type === "regex") {
77
+ try {
78
+ const re = new RegExp(raw, ci ? "i" : ""); // компилируем ОДИН раз
79
+ tests.push((p) => re.test(p));
80
+ } catch {
81
+ /* битый regex — пропускаем только этот источник */
82
+ }
83
+ } else {
84
+ // exact/start — пути (нормализуем как путь); contains/end — под-строка/суффикс
85
+ // (берём как есть, только trim), иначе навязанный '/' ломает совпадение (".html").
86
+ const norm = src.type === "exact" || src.type === "start" ? normSource(raw) : raw.trim();
87
+ const v = ci ? norm.toLowerCase() : norm;
88
+ const f = (p: string) => (ci ? p.toLowerCase() : p);
89
+ if (src.type === "exact") tests.push((p) => f(p) === v);
90
+ else if (src.type === "contains") tests.push((p) => f(p).includes(v));
91
+ else if (src.type === "start") tests.push((p) => f(p).startsWith(v));
92
+ else if (src.type === "end") tests.push((p) => f(p).endsWith(v));
93
+ }
94
+ }
95
+ if (tests.length) out.push({ test: (p) => tests.some((t) => t(p)), destination: r.destination, code });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ let cache: { at: number; ttl: number; rules: CompiledRule[] | null } | null = null;
101
+ let inflight: Promise<CompiledRule[] | null> | null = null;
102
+
103
+ async function loadRules(): Promise<CompiledRule[] | null> {
104
+ if (!API || !SITE || !TOKEN) return null;
105
+ try {
106
+ const r = await fetch(`${API}/api/seo/${SITE}/redirects`, {
107
+ headers: { "x-seo-agent-key": TOKEN },
108
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
109
+ // без next:{revalidate,tags} — в Edge middleware не действуют
110
+ });
111
+ if (!r.ok) return null;
112
+ const body = (await r.json()) as { rules?: RawRule[] } | RawRule[] | null;
113
+ const raw = Array.isArray(body) ? body : (body?.rules ?? []);
114
+ return compile(Array.isArray(raw) ? raw : []);
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ async function getRules(): Promise<CompiledRule[] | null> {
121
+ const now = Date.now();
122
+ if (cache && now - cache.at < cache.ttl) return cache.rules;
123
+ if (inflight) return inflight; // дедуп конкурентных промахов на холодном изоляте
124
+ inflight = loadRules()
125
+ .then((rules) => {
126
+ cache = { at: Date.now(), ttl: rules === null ? TTL_FAIL : TTL_OK, rules };
127
+ inflight = null;
128
+ return rules;
129
+ })
130
+ .catch(() => {
131
+ inflight = null;
132
+ return cache?.rules ?? null;
133
+ });
134
+ return inflight;
135
+ }
136
+
137
+ // Вызывать ПЕРВЫМ в middleware: `const r = await seoRedirects(request); if (r) return r;`
138
+ export async function seoRedirects(request: NextRequest): Promise<NextResponse | null> {
139
+ try {
140
+ const rules = await getRules();
141
+ if (!rules || rules.length === 0) return null;
142
+ const path = normPath(request.nextUrl.pathname);
143
+
144
+ for (const rule of rules) {
145
+ if (!rule.test(path)) continue;
146
+
147
+ if (rule.code === 410 || rule.code === 451) {
148
+ const msg = rule.code === 410 ? "410 Gone" : "451 Unavailable For Legal Reasons";
149
+ return new NextResponse(msg, { status: rule.code, headers: { "content-type": "text/plain; charset=utf-8" } });
150
+ }
151
+
152
+ const abs = /^https?:\/\//i.test(rule.destination);
153
+ const target = abs ? new URL(rule.destination) : new URL(rule.destination, request.url);
154
+
155
+ // Защита от петли: назначение = текущий путь того же origin — пропускаем.
156
+ if (target.origin === request.nextUrl.origin && normPath(target.pathname) === path) continue;
157
+
158
+ // Сохраняем входящий query, если у назначения своего нет.
159
+ if (!target.search && request.nextUrl.search) target.search = request.nextUrl.search;
160
+ return NextResponse.redirect(target, rule.code);
161
+ }
162
+ return null;
163
+ } catch {
164
+ return null; // любая ошибка → сайт продолжает работать
165
+ }
166
+ }