#!/usr/bin/env node import { readFileSync } from 'node:fs'; import { expectedMainSpaRoutes } from './check-nginx-spa-routes.mjs'; const MATRIX_PATH = 'deploy/pingora/nginx-route-parity.matrix.json'; const PRODUCTION_NGINX_PATH = 'deploy/nginx/genarrative.conf'; const DEVELOPMENT_NGINX_PATH = 'deploy/nginx/genarrative-dev-http.conf'; const PINGORA_DOC_PATH = 'docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md'; const PINGORA_GATEWAY_SOURCE = 'server-rs/crates/pingora-gateway/src/main.rs'; const VALID_KINDS = new Set([ 'proxy', 'static', 'redirect_permanent', 'shadow_probe', 'not_found', ]); const VALID_PROXY_TARGETS = new Set(['api', 'spacetime']); const VALID_STATIC_ROOTS = new Set(['web', 'acme']); const VALID_STATIC_MODES = new Set(['exact', 'spa_fallback']); const VALID_PROTECTION_CLASSES = new Set([ 'admin_api', 'api', 'spacetime', ]); const REQUIRED_ROUTE_IDS = [ 'acme_challenge', 'shadow_probe', 'admin_redirect', 'admin_api_proxy', 'admin_assets', 'admin_spa_fallback', 'web_assets', 'generic_api_proxy', 'spacetime_subscribe', 'spacetime_identity', 'v1_forbidden', 'healthz_forbidden', 'readyz_forbidden', 'generated_assets_forbidden', 'web_spa_fallback', 'profile_spa_fallback', 'web_spa_case_trailing_slash', 'web_unknown_path_exact', 'creation_unknown_path_exact', 'runtime_unknown_path_exact', 'puzzle_unknown_path_exact', ]; const files = { production: readFileSync(PRODUCTION_NGINX_PATH, 'utf8'), development: readFileSync(DEVELOPMENT_NGINX_PATH, 'utf8'), }; const docs = readFileSync(PINGORA_DOC_PATH, 'utf8'); const pingoraGatewaySource = readFileSync(PINGORA_GATEWAY_SOURCE, 'utf8'); const matrix = JSON.parse(readFileSync(MATRIX_PATH, 'utf8')); const failures = []; function fail(message) { failures.push(message); } function hasOwn(object, key) { return Object.prototype.hasOwnProperty.call(object, key); } function requireString(value, context) { if (typeof value !== 'string' || value.trim() === '') { fail(`${context} 必须是非空字符串。`); return false; } return true; } function validateExpectation(route) { const context = `${MATRIX_PATH} route ${route.id}`; const expect = route.expect; if (!expect || typeof expect !== 'object' || Array.isArray(expect)) { fail(`${context} 缺少 expect 对象。`); return; } if (!VALID_KINDS.has(expect.kind)) { fail(`${context} expect.kind 不支持: ${expect.kind}`); return; } if (expect.kind === 'proxy') { if (!VALID_PROXY_TARGETS.has(expect.target)) { fail(`${context} proxy target 不支持: ${expect.target}`); } if ( hasOwn(expect, 'bodyLimit') && expect.bodyLimit !== null && expect.bodyLimit !== 'default' && (!Number.isInteger(expect.bodyLimit) || expect.bodyLimit < 1) ) { fail(`${context} bodyLimit 必须是 null、default 或正整数。`); } if (!VALID_PROTECTION_CLASSES.has(expect.protectionClass)) { fail( `${context} proxy protectionClass 不支持: ${expect.protectionClass}`, ); } return; } if (hasOwn(expect, 'protectionClass')) { fail(`${context} 非 proxy 路由不能配置 protectionClass。`); } if (expect.kind === 'static') { if (!VALID_STATIC_ROOTS.has(expect.root)) { fail(`${context} static root 不支持: ${expect.root}`); } if (!VALID_STATIC_MODES.has(expect.mode)) { fail(`${context} static mode 不支持: ${expect.mode}`); } } if ( expect.kind === 'redirect_permanent' && !requireString(expect.location, `${context} redirect location`) ) { fail(`${context} redirect_permanent 必须配置 location。`); } } function validateNginxFragments(route) { for (const environment of ['production', 'development']) { const fragments = route.nginx?.[environment]; if (fragments === undefined) { if (environment === 'production' && route.id !== 'shadow_probe') { fail(`${MATRIX_PATH} route ${route.id} 缺少 production Nginx 片段。`); } continue; } if (!Array.isArray(fragments) || fragments.length === 0) { fail( `${MATRIX_PATH} route ${route.id} 的 ${environment} Nginx 片段不能为空。`, ); continue; } for (const fragment of fragments) { if (!requireString(fragment, `${route.id} ${environment} Nginx 片段`)) { continue; } if (!files[environment].includes(fragment)) { fail( `${environment} Nginx 模板缺少 route ${route.id} 片段: ${fragment}`, ); } } } } function validateDocFragments(route) { if (!Array.isArray(route.docs) || route.docs.length === 0) { fail(`${MATRIX_PATH} route ${route.id} 缺少 docs 片段。`); return; } for (const fragment of route.docs) { if (!requireString(fragment, `${route.id} docs 片段`)) { continue; } if (!docs.includes(fragment)) { fail(`Pingora 试点文档缺少 route ${route.id} 片段: ${fragment}`); } } } function validateMatrixShape() { if (matrix.version !== 1) { fail(`${MATRIX_PATH} version 必须为 1。`); } if (!Array.isArray(matrix.routes) || matrix.routes.length === 0) { fail(`${MATRIX_PATH} routes 不能为空。`); return; } const ids = new Set(); const samplePaths = new Set(); for (const route of matrix.routes) { if (!requireString(route.id, `${MATRIX_PATH} route.id`)) { continue; } if (ids.has(route.id)) { fail(`${MATRIX_PATH} route id 重复: ${route.id}`); } ids.add(route.id); if (!requireString(route.samplePath, `${route.id} samplePath`)) { continue; } if (!route.samplePath.startsWith('/')) { fail(`${MATRIX_PATH} route ${route.id} samplePath 必须以 / 开头。`); } if (samplePaths.has(route.samplePath)) { fail(`${MATRIX_PATH} samplePath 重复: ${route.samplePath}`); } samplePaths.add(route.samplePath); validateExpectation(route); validateNginxFragments(route); validateDocFragments(route); } for (const routeId of REQUIRED_ROUTE_IDS) { if (!ids.has(routeId)) { fail(`${MATRIX_PATH} 缺少必需 route id: ${routeId}`); } } } function validateRustTestUsesMatrix() { for (const fragment of [ 'include_str!("../../../../deploy/pingora/nginx-route-parity.matrix.json")', 'serde_json::from_str(ROUTE_PARITY_MATRIX_JSON)', 'protection_class_for_route(&route, &case.sample_path)', 'fn matches_nginx_route_parity_matrix()', 'fn is_main_spa_path(path: &str)', "path.strip_suffix('/')", 'normalized.eq_ignore_ascii_case(candidate)', ]) { if (!pingoraGatewaySource.includes(fragment)) { fail(`Pingora Rust 路由 parity 测试缺少矩阵接入片段: ${fragment}`); } } } function validateRustMainSpaRoutes() { const routeBlock = pingoraGatewaySource.match( /const MAIN_SPA_PATHS: &\[&str\] = &\[([\s\S]*?)\];/u, ); if (!routeBlock) { fail('Pingora Rust 缺少 MAIN_SPA_PATHS allowlist。'); return; } const rustRoutes = Array.from( routeBlock[1].matchAll(/"([^"]+)"/gu), (match) => match[1], ).sort(); const expected = new Set(expectedMainSpaRoutes); const actual = new Set(rustRoutes); const missing = expectedMainSpaRoutes.filter((route) => !actual.has(route)); const extra = rustRoutes.filter((route) => !expected.has(route)); if (missing.length > 0) { fail(`Pingora MAIN_SPA_PATHS 缺少当前前端路由: ${missing.join(', ')}`); } if (extra.length > 0) { fail(`Pingora MAIN_SPA_PATHS 包含非当前前端路由: ${extra.join(', ')}`); } } validateMatrixShape(); validateRustTestUsesMatrix(); validateRustMainSpaRoutes(); if (failures.length > 0) { console.error('[check:pingora-route-parity] FAILED'); for (const failure of failures) { console.error(`- ${failure}`); } process.exit(1); } console.log(`[check:pingora-route-parity] OK (${matrix.routes.length} routes)`);