|
| 1 | +/** |
| 2 | + * Verify that every HTTP call the frontend makes matches a registered route — same verb, |
| 3 | + * same path. |
| 4 | + * |
| 5 | + * This exists because a product-edit form once posted `_method: 'PUT'` in the request body, |
| 6 | + * a Laravel idiom that AdonisJS does not honour (the bodyparser is router middleware, so it |
| 7 | + * runs after routing). Routes are indexed by method, and a verb miss answers 404 rather than |
| 8 | + * 405 Method Not Allowed — so it looked like a wrong URL and shipped to production. |
| 9 | + * |
| 10 | + * The route table comes from `node ace list:routes --json`, not from parsing routes.ts, so |
| 11 | + * group prefixes and named middleware cannot skew it. |
| 12 | + * |
| 13 | + * Usage: npm run check:routes |
| 14 | + */ |
| 15 | + |
| 16 | +import { execFileSync } from 'node:child_process' |
| 17 | +import { readdirSync, readFileSync, statSync } from 'node:fs' |
| 18 | +import { join, relative } from 'node:path' |
| 19 | +import { fileURLToPath } from 'node:url' |
| 20 | + |
| 21 | +const repoRoot = fileURLToPath(new URL('..', import.meta.url)) |
| 22 | + |
| 23 | +type Route = { methods: string[]; pattern: string; matcher: RegExp } |
| 24 | +type Call = { file: string; line: number; method: string; url: string; source: string } |
| 25 | + |
| 26 | +function loadRoutes(): Route[] { |
| 27 | + const raw = execFileSync('node', ['ace', 'list:routes', '--json'], { |
| 28 | + cwd: repoRoot, |
| 29 | + encoding: 'utf8', |
| 30 | + env: { ...process.env, NODE_ENV: process.env.NODE_ENV ?? 'test' }, |
| 31 | + maxBuffer: 32 * 1024 * 1024, |
| 32 | + }) |
| 33 | + |
| 34 | + const domains = JSON.parse(raw) as Array<{ |
| 35 | + routes: Array<{ methods: string[]; pattern: string }> |
| 36 | + }> |
| 37 | + |
| 38 | + return domains |
| 39 | + .flatMap((domain) => domain.routes) |
| 40 | + .map((route) => ({ |
| 41 | + methods: route.methods, |
| 42 | + pattern: route.pattern, |
| 43 | + matcher: patternToRegex(route.pattern), |
| 44 | + })) |
| 45 | +} |
| 46 | + |
| 47 | +function patternToRegex(pattern: string): RegExp { |
| 48 | + const source = pattern |
| 49 | + .split('/') |
| 50 | + .map((segment) => { |
| 51 | + if (segment.startsWith('*')) return '.*' |
| 52 | + if (segment.startsWith(':')) return '[^/]+' |
| 53 | + return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') |
| 54 | + }) |
| 55 | + .join('/') |
| 56 | + |
| 57 | + return new RegExp(`^${stripTrailingSlash(source)}$`) |
| 58 | +} |
| 59 | + |
| 60 | +function stripTrailingSlash(value: string): string { |
| 61 | + return value.replace(/\/+$/, '') || '/' |
| 62 | +} |
| 63 | + |
| 64 | +/** `${...}` becomes a param placeholder; the query string is irrelevant to routing. */ |
| 65 | +function normalizeUrl(url: string): string { |
| 66 | + return stripTrailingSlash(url.replace(/\$\{[^}]*\}/g, ':param').replace(/\?.*$/, '')) |
| 67 | +} |
| 68 | + |
| 69 | +function walk(dir: string, out: string[] = []): string[] { |
| 70 | + for (const entry of readdirSync(dir)) { |
| 71 | + const path = join(dir, entry) |
| 72 | + if (statSync(path).isDirectory()) walk(path, out) |
| 73 | + else if (/\.(vue|ts|js)$/.test(entry)) out.push(path) |
| 74 | + } |
| 75 | + return out |
| 76 | +} |
| 77 | + |
| 78 | +const CALL_PATTERN = /\.(get|post|put|patch|delete)\(\s*[`'"](\/[^`'"]*)[`'"]/g |
| 79 | +const GOTO_PATTERN = /\.goto\(\s*[`'"](\/[^`'"]*)[`'"]/g |
| 80 | +const FORM_COMPONENT_PATTERN = |
| 81 | + /<Form\b[^>]*?\bmethod=["']([a-z]+)["'][^>]*?\baction=["'](\/[^"']*)["']/gs |
| 82 | + |
| 83 | +function collectCalls(files: string[]): Call[] { |
| 84 | + const calls: Call[] = [] |
| 85 | + |
| 86 | + for (const file of files) { |
| 87 | + const source = readFileSync(file, 'utf8') |
| 88 | + const lines = source.split('\n') |
| 89 | + const lineOf = (index: number) => source.slice(0, index).split('\n').length |
| 90 | + |
| 91 | + const push = (index: number, method: string, url: string, label?: string) => { |
| 92 | + const line = lineOf(index) |
| 93 | + calls.push({ |
| 94 | + file, |
| 95 | + line, |
| 96 | + method: method.toUpperCase(), |
| 97 | + url, |
| 98 | + source: label ?? lines[line - 1]?.trim() ?? '', |
| 99 | + }) |
| 100 | + } |
| 101 | + |
| 102 | + for (const match of source.matchAll(CALL_PATTERN)) push(match.index!, match[1], match[2]) |
| 103 | + for (const match of source.matchAll(GOTO_PATTERN)) push(match.index!, 'GET', match[1], 'goto()') |
| 104 | + for (const match of source.matchAll(FORM_COMPONENT_PATTERN)) { |
| 105 | + push(match.index!, match[1], match[2], '<Form>') |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + return calls |
| 110 | +} |
| 111 | + |
| 112 | +const routes = loadRoutes() |
| 113 | +const files = [...walk(join(repoRoot, 'inertia')), ...walk(join(repoRoot, 'tests/e2e'))] |
| 114 | +const calls = collectCalls(files) |
| 115 | + |
| 116 | +const problems: string[] = [] |
| 117 | + |
| 118 | +for (const call of calls) { |
| 119 | + const url = normalizeUrl(call.url) |
| 120 | + const pathMatches = routes.filter((route) => route.matcher.test(url)) |
| 121 | + |
| 122 | + if (pathMatches.length === 0) { |
| 123 | + problems.push( |
| 124 | + `${relative(repoRoot, call.file)}:${call.line} ${call.method} ${url}\n` + |
| 125 | + ` no route matches this path ${call.source}` |
| 126 | + ) |
| 127 | + continue |
| 128 | + } |
| 129 | + |
| 130 | + if (!pathMatches.some((route) => route.methods.includes(call.method))) { |
| 131 | + const allowed = [...new Set(pathMatches.flatMap((route) => route.methods))].join(', ') |
| 132 | + problems.push( |
| 133 | + `${relative(repoRoot, call.file)}:${call.line} ${call.method} ${url}\n` + |
| 134 | + ` route ${pathMatches[0].pattern} only accepts ${allowed} ${call.source}` |
| 135 | + ) |
| 136 | + } |
| 137 | +} |
| 138 | + |
| 139 | +console.log(`Route table: ${routes.length} routes`) |
| 140 | +console.log(`Frontend + e2e calls checked: ${calls.length}`) |
| 141 | + |
| 142 | +if (problems.length > 0) { |
| 143 | + console.error(`\n${problems.length} call(s) cannot reach a route:\n`) |
| 144 | + for (const problem of problems) console.error(` ${problem}\n`) |
| 145 | + console.error('A verb mismatch answers 404, not 405 — fix the call or the route.') |
| 146 | + process.exit(1) |
| 147 | +} |
| 148 | + |
| 149 | +console.log('All calls match a registered route.') |
0 commit comments