diff --git a/AGENTS.md b/AGENTS.md index 87f928649..9a297c2b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the - `src/index.ts` — `CodeGraph` class: `init`/`open`/`close`, `indexAll`, `sync`, `searchNodes`, `getCallers`/`getCallees`, `getImpactRadius`, `buildContext`, `watch`/`unwatch`. - `src/db/` — `DatabaseConnection`, `QueryBuilder` (prepared statements), `schema.sql`, `sqlite-adapter.ts`. Backed by Node's built-in **`node:sqlite`** (`DatabaseSync`) — real SQLite with WAL + FTS5, exposed through a thin better-sqlite3-shaped adapter. The bundled runtime always ships Node ≥22.5, so `node:sqlite` is always available: **no native build step and no wasm fallback**. (Running from source needs Node ≥22.5.) `codegraph status` reports the live backend (`node-sqlite`, the sole backend). - `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread. -- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, CodeIgniter 3, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, Next.js — `nextjs.ts`: pages and `route.ts` handlers from files, `router.push` / `redirect` / `NextResponse.redirect` as `navigates` edges, with `next-router-synthesizer.ts` for `` — Expo Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`). +- `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, CodeIgniter 3, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, Next.js — `nextjs.ts`: pages and `route.ts` handlers from files, `router.push` / `redirect` / `NextResponse.redirect` as `navigates` edges, with `next-router-synthesizer.ts` for `` and for the routes a proxy's `config.matcher` accepts, read by `next-proxy.ts` — Expo Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`). - `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries), plus the shared query-time derivations more than one surface renders: `named-symbol-flow.ts` (the one path finder, behind `codegraph_explore`'s Flow section and the viewer's Flow strip), `dynamic-boundary-report.ts` (where the graph stops), `type-hierarchy.ts` (ancestors/subtypes and the implementation count explore prints and the viewer draws), `dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed), `synthesized-hop.ts` (how a synthesized hop reads, in explore's Flow, the `codegraph_node` trail and ContextBuilder's call paths — a new synthesizer's wording goes here), `file-cycles.ts` (circular imports, in `codegraph cycles` and the viewer's map). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree. - `src/context/` — `ContextBuilder` + formatter for markdown/JSON output. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b25569cc..20cdf37c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph init --local-only` keeps `.codegraph/` out of git entirely, so nothing of CodeGraph's shows in `git status`, and it works on a project that's already set up, leaving a `.codegraph/.gitignore` you've edited or committed alone (#910). Thanks @jadchene, @jiangyq9, @Wood-Q and @walterf3. - CodeIgniter 3 projects are now understood: every controller method and `routes.php` entry becomes a route, HMVC modules included, and calls through what `$this->load` loads, like `$this->user_model->find($id)`, link to the method they reach, so `codegraph_explore` can follow a request from its route through the model and into the view. Thanks @darkspock. - In Drupal, services from `*.services.yml`, plugin definitions (attributes and docblock annotations, a contrib module's own plugin types included) and `#[Hook]` methods are now part of the graph, and a call through `\Drupal::service('x')` or `$container->get('x')` links to the method on that service's class. Thanks @mikedamoiseau and @marcelovani. +- In Next.js, a route now links to the `proxy.ts` or `middleware.ts` that runs before it, following `config.matcher`, so `codegraph impact`, `codegraph callers` and `codegraph_explore` show which routes a change to it reaches. Thanks @RIGOandre. ### Fixes diff --git a/__tests__/nextjs-proxy.test.ts b/__tests__/nextjs-proxy.test.ts new file mode 100644 index 000000000..3aa7d3c4a --- /dev/null +++ b/__tests__/nextjs-proxy.test.ts @@ -0,0 +1,458 @@ +/** + * Next.js proxy (`proxy.ts`, `middleware.ts` before Next 16): each route its + * `config.matcher` accepts runs it first, so a route links to what runs in it + * and a flow or an impact can cross a boundary no import spans. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; +import { compileMatcher, matcherCovers, nextProxyDir, readProxyMatcher } from '../src/resolution/frameworks/next-proxy'; +import { stripCommentsForRegex } from '../src/resolution/strip-comments'; +import { routeRoots } from '../src/ui-server/api/route-roots'; +import { buildScreens } from '../src/ui-server/api/screens'; +import { resolveNamedSymbolFlow } from '../src/graph/named-symbol-flow'; +import type { Edge, Node } from '../src/types'; + +const covers = (source: string, route: string, file = 'app/x/page.tsx'): boolean | null => { + const regex = compileMatcher(source); + return regex === null ? null : matcherCovers(regex, route, file); +}; + +describe('a matcher entry', () => { + it.each([ + ['/about', '/about', true], + ['/about', '/about/team', false], + ['/about', '/blog/about', false], + ['/', '/', true], + ['/', '/about', false], + ['/about/:rest', '/about', false], + ['/about/:rest', '/about/team', true], + ['/about/:rest', '/about/team/lead', false], + ['/about/:rest*', '/about', true], + ['/about/:rest*', '/about/team/lead', true], + ['/about/:rest*', '/aboutus', false], + ['/about/:rest+', '/about', false], + ['/about/:rest+', '/about/team/lead', true], + ['/about/:rest?', '/about', true], + ['/about/:rest?', '/about/team', true], + ['/about/:rest?', '/about/team/lead', false], + ['/:rest*', '/', true], + ['/:rest*', '/a/b/c', true], + ['/:locale/about', '/en/about', true], + ['/blog/:slug', '/blog/:id', true], + ['/api/:rest*', '/api/projects/:id', true], + ])('%s against %s: %s', (source, route, expected) => { + expect(covers(source, route)).toBe(expected); + }); + + it('reads the forms a regular expression takes in real apps', () => { + // The negative lookahead of the Next docs. + const starter = '/((?!api|_next/static|_next/image|favicon.ico).*)'; + expect(covers(starter, '/')).toBe(true); + expect(covers(starter, '/dashboard/:id')).toBe(true); + expect(covers(starter, '/api/users')).toBe(false); + expect(covers(starter, '/apikeys')).toBe(false); // a prefix, as the regex reads it + + // Clerk's: everything but Next's own files and files with an extension. + const clerk = '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)'; + expect(covers(clerk, '/dashboard')).toBe(true); + expect(covers(clerk, '/feed.xml')).toBe(false); + expect(covers('/(api|trpc)(.*)', '/api/users')).toBe(true); + expect(covers('/(api|trpc)(.*)', '/trpc')).toBe(true); + expect(covers('/(api|trpc)(.*)', '/users')).toBe(false); + + expect(covers('/((?!.*\\..*|_next).*)', '/pricing')).toBe(true); + expect(covers('/((?!_next|.*\\.png$|.*\\.jpg$|.*\\.svg$).*)', '/pricing')).toBe(true); + expect(covers('/((?!api/|_next/|_static|_vercel|[\\w-]+\\.\\w+).*)', '/api/users')).toBe(false); + expect(covers('/((?!api/|_next/|_static|_vercel|[\\w-]+\\.\\w+).*)', '/app')).toBe(true); + expect(covers('/((?!api(?:/|$)|_next).*)', '/api')).toBe(false); + expect(covers('/((?!api(?:/|$)|_next).*)', '/apikeys')).toBe(true); + expect(covers('/((?!sitemap.xml$).*)', '/sitemap.xml')).toBe(false); + expect(covers('/((?!_next).+)', '/')).toBe(false); + expect(covers('/((?!_next).+)', '/x')).toBe(true); + expect(covers('/dashboard(.*)', '/dashboard')).toBe(true); + expect(covers('/dashboard(.*)', '/dashboard/settings')).toBe(true); + expect(covers('/about/(.*)', '/about/team')).toBe(true); + expect(covers('/:locale(en|fr)/about', '/en/about')).toBe(true); + expect(covers('/:locale(en|fr)/about', '/de/about')).toBe(false); + }); + + it('never lets a literal stand in for a route parameter: a static page beside it would own that address', () => { + expect(covers('/blog/featured', '/blog/:slug', 'app/blog/[slug]/page.tsx')).toBe(false); + expect(covers('/docs/intro', '/docs/:all*', 'app/docs/[...all]/page.tsx')).toBe(false); + }); + + it('tells a required catch-all from an optional one by the file', () => { + expect(covers('/docs', '/docs/:all*', 'app/docs/[...all]/page.tsx')).toBe(false); + expect(covers('/docs', '/docs/:all*', 'app/docs/[[...all]]/page.tsx')).toBe(true); + expect(covers('/docs/:p', '/docs/:all*', 'app/docs/[...all]/page.tsx')).toBe(true); + }); + + it('does not take an unescaped dot for a file extension: that pattern takes every path, and the proxy skips them', () => { + // Written '/((?!.*\..*|_next).*)' in a JavaScript string, it reaches Next like this. + expect(compileMatcher('/((?!.*..*|_next).*)')).toBeNull(); + }); + + it.each([ + '/about/:rest(\\d+)', + '/feed-(\\d+)', + '/api/*', + 'about', + '/((?!\\d+).*)', + '/(a+)+', + '/(.*)(.*)(.*)', + '/:a*/:b*/:c*', + `/${'a'.repeat(3000)}`, + ])('%s is not read: no link, never a guess', (source) => { + expect(compileMatcher(source)).toBeNull(); + }); + + it('holds up against a pattern written to hang a regular expression', () => { + const started = Date.now(); + const sources = ['/(a|a)(a|a)(a|a)(a|a)', '/((?!(a+)+$).*)', `/${'(.*)'.repeat(40)}`, '/(.*)(.*)x', '/:a*/:b*x']; + for (const source of sources) { + const regex = compileMatcher(source); + if (regex) matcherCovers(regex, `/${'a/'.repeat(2000)}a`, 'app/x/page.tsx'); + } + expect(Date.now() - started).toBeLessThan(500); + }); +}); + +describe('reading config.matcher', () => { + const read = (src: string): string[] | 'all' => { + const out = readProxyMatcher(src); + return out === 'all' ? out : out.map((e) => e.source); + }; + + it('is every path with no config, or a config with no matcher', () => { + expect(read('export function proxy() {}\n')).toBe('all'); + expect(read("export const config = { runtime: 'nodejs' }\n")).toBe('all'); + }); + + it('reads a string, and an array of strings and { source } objects', () => { + expect(read("export const config = { matcher: '/about/:rest*' }\n")).toEqual(['/about/:rest*']); + expect(read( + 'export const config: MiddlewareConfig = {\n' + + ' matcher: [\n' + + " '/dashboard/:rest*',\n" + + " { source: '/api/:rest*', missing: [{ type: 'header', key: 'x-internal' }] },\n" + + ' `/account`,\n' + + ' ],\n' + + '}\n' + )).toEqual(['/dashboard/:rest*', '/api/:rest*', '/account']); + }); + + it('unescapes the string the way the matcher sees it', () => { + expect(read("export const config = { matcher: ['/((?!.*\\\\..*|_next).*)'] }\n")).toEqual(['/((?!.*\\..*|_next).*)']); + }); + + it('reads as nothing what Next could not read either', () => { + expect(read("const extra = '/x'\nexport const config = { matcher: ['/kept', extra] }\n")).toEqual([]); + expect(read("export const config = { matcher: ['/kept', ...more] }\n")).toEqual([]); + expect(read("export const config = { matcher: ['/kept', { source: `/u/${id}` }] }\n")).toEqual([]); + expect(read('export const config = { matcher: PROTECTED }\n')).toEqual([]); + expect(read('const matcher = ["/a"]\nexport const config = { matcher }\n')).toEqual([]); + expect(read('export const config = makeConfig()\n')).toEqual([]); + }); + + it('skips a matcher in a comment', () => { + const src = "// export const config = { matcher: '/old' }\nexport const config = {\n /* '/older' */ matcher: '/new',\n}\n"; + expect(read(stripCommentsForRegex(src, 'typescript'))).toEqual(['/new']); + }); + + it('says where each entry is written', () => { + const src = "export const config = {\n matcher: ['/a', '/b'],\n}\n"; + const out = readProxyMatcher(src) as { index: number }[]; + expect(out.map((e) => src.slice(e.index, e.index + 4))).toEqual(["'/a'", "'/b'"]); + }); +}); + +describe('where Next loads the proxy from', () => { + it.each([ + ['', ['app/page.tsx'], ''], + ['', ['src/app/page.tsx'], 'src/'], + ['apps/web/', ['apps/web/src/app/dashboard/page.tsx'], 'apps/web/src/'], + ['', ['pages/index.tsx', 'src/app/page.tsx'], ''], + ])('an app at "%s" with %j: "%s"', (root, files, dir) => { + expect(nextProxyDir(root, files)).toBe(dir); + }); +}); + +describe('a route and the proxy that runs before it', () => { + const dirs: string[] = []; + const graphs: CodeGraph[] = []; + + beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); + }); + + afterAll(() => { + for (const cg of graphs) cg.close(); + for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true }); + }); + + function write(dir: string, rel: string, content: string): void { + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + async function project(files: Record): Promise<{ cg: CodeGraph; dir: string }> { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-next-proxy-')); + dirs.push(dir); + for (const [rel, content] of Object.entries(files)) write(dir, rel, content); + const cg = CodeGraph.initSync(dir); + graphs.push(cg); + await cg.indexAll(); + return { cg, dir }; + } + + const page = (name: string): string => `export default function ${name}() {\n return null\n}\n`; + const manifest = (next: string): string => JSON.stringify({ name: 'web', dependencies: { next, react: '19.0.0' } }); + const meta = (e: Edge): Record => (e.metadata ?? {}) as Record; + const proxyEdges = (cg: CodeGraph): Edge[] => + cg.getNodesByKind('route').flatMap((r) => cg.getOutgoingEdges(r.id).filter((e) => meta(e).synthesizedBy === 'next-proxy')); + const routesRunning = (cg: CodeGraph, target: Node): Record => + Object.fromEntries( + proxyEdges(cg).filter((e) => e.target === target.id).map((e) => [cg.getNode(e.source)!.name, meta(e).matcher ?? null]) + ); + const fn = (cg: CodeGraph, name: string, filePath: string): Node => { + const node = cg.getNodesByName(name).find((n) => n.filePath === filePath && n.kind === 'function'); + if (!node) throw new Error(`no function ${name} in ${filePath}`); + return node; + }; + + it('links the routes the matcher accepts, under src/, and only from the proxy Next loads', async () => { + const { cg } = await project({ + 'package.json': manifest('^16.2.1'), + 'src/app/page.tsx': page('Home'), + 'src/app/login/page.tsx': page('Login'), + 'src/app/dashboard/page.tsx': page('Dashboard'), + 'src/app/dashboard/settings/page.tsx': page('Settings'), + 'src/app/blog/[slug]/page.tsx': page('Post'), + 'src/app/docs/[[...all]]/page.tsx': page('Docs'), + 'src/app/api/projects/[id]/route.ts': + 'export async function GET() {\n return Response.json({})\n}\nexport async function POST() {\n return Response.json({})\n}\n', + 'src/lib/session.ts': 'export function readSession(cookie) {\n return cookie ?? null\n}\n', + 'src/proxy.ts': + "import { NextResponse } from 'next/server'\n" + + "import { readSession } from './lib/session'\n" + + '\n' + + 'export function proxy(request) {\n' + + " if (!readSession(request.cookies.get('session'))) return NextResponse.redirect(new URL('/login', request.url))\n" + + ' return NextResponse.next()\n' + + '}\n' + + '\n' + + 'export const config = {\n' + + ' matcher: [\n' + + " '/dashboard/:rest*',\n" + + " { source: '/api/:rest+', missing: [{ type: 'header', key: 'x-internal' }] },\n" + + " '/docs',\n" + + ' ],\n' + + '}\n', + // Not where Next loads a proxy for an app under src/. + 'proxy.ts': 'export function proxy(request) {\n return request\n}\n', + 'src/lib/proxy.ts': 'export function proxy(target) {\n return fetch(target)\n}\n', + }); + const proxy = fn(cg, 'proxy', 'src/proxy.ts'); + + expect(routesRunning(cg, proxy)).toEqual({ + '/dashboard': '/dashboard/:rest*', + '/dashboard/settings': '/dashboard/:rest*', + '/docs/:all*': '/docs', + 'GET /api/projects/:id': '/api/:rest+', + 'POST /api/projects/:id': '/api/:rest+', + }); + expect(proxyEdges(cg).every((e) => e.target === proxy.id)).toBe(true); + + const edge = proxyEdges(cg).find((e) => cg.getNode(e.source)!.name === '/dashboard')!; + expect(edge.kind).toBe('calls'); + expect(edge.provenance).toBe('heuristic'); + expect(edge.metadata).toEqual({ synthesizedBy: 'next-proxy', matcher: '/dashboard/:rest*', registeredAt: 'src/proxy.ts:11' }); + }); + + it('makes the routes the proxy\'s callers, its impact, and a flow from a route reach what the proxy calls', async () => { + const { cg } = await project({ + 'package.json': manifest('15.5.0'), + 'app/dashboard/page.tsx': page('Dashboard'), + 'app/about/page.tsx': page('About'), + 'app/page.tsx': "import Link from 'next/link'\nexport default function Home() {\n return Dashboard\n}\n", + 'lib/session.ts': 'export function readSession(cookie) {\n return cookie ?? null\n}\n', + 'middleware.ts': + "import { readSession } from './lib/session'\n" + + 'export function middleware(request) {\n return readSession(request)\n}\n' + + "export const config = { matcher: '/dashboard/:rest*' }\n", + }); + const middleware = fn(cg, 'middleware', 'middleware.ts'); + expect(cg.getCallers(middleware.id).map((c) => c.node.name)).toEqual(['/dashboard']); + expect([...cg.getImpactRadius(fn(cg, 'readSession', 'lib/session.ts').id, 2).nodes.values()].map((n) => n.name)).toContain('/dashboard'); + + // Nothing names a route in a query: a flow crosses one, from the link that goes there. + const flow = resolveNamedSymbolFlow(cg, 'Home readSession'); + expect(flow.chains[0]?.steps.map((step) => step.node.name)).toEqual(['Home', '/dashboard', 'middleware', 'readSession']); + }); + + it('leaves where the route\'s code starts alone, even for a proxy named like a component', async () => { + const { cg } = await project({ + 'package.json': manifest('15.5.0'), + 'app/page.tsx': page('Home'), + 'app/about/route.ts': 'export async function GET() {\n return new Response(null)\n}\n', + 'middleware.ts': 'export default function Gate(request) {\n return undefined\n}\n', + }); + const routes = cg.getNodesByKind('route'); + expect(proxyEdges(cg)).toHaveLength(2); + const roots = routeRoots(cg, routes); + expect(routes.map((r) => roots.get(r.id)?.node.name).sort()).toEqual(['GET', 'Home']); + }); + + it('keeps a redirect the proxy makes on Screens, however many routes it runs before', async () => { + // More routes than the walk back from a navigation will take callers of one node. + const pages = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`app/p${i}/page.tsx`, page(`P${i}`)])); + const { cg, dir } = await project({ + 'package.json': manifest('15.5.0'), + ...pages, + 'app/login/page.tsx': page('Login'), + 'lib/gate.ts': + "import { NextResponse } from 'next/server'\n" + + "export function gate(request) {\n return NextResponse.redirect(new URL('/login', request.url))\n}\n", + 'middleware.ts': + "import { gate } from './lib/gate'\n" + + 'export function middleware(request) {\n return gate(request)\n}\n' + + "export const config = { matcher: '/((?!api|_next).*)' }\n", + }); + expect(proxyEdges(cg)).toHaveLength(33); + const screens = await buildScreens(cg, dir); + expect(screens.dropped).toBe(0); + expect(screens.origins.map((o) => o.node.name)).toEqual(['gate']); + expect(screens.links.filter((l) => l.fromOrigin)).toHaveLength(1); + }); + + it('takes the default export for the entry in each way it is written, and a proxy.ts beside middleware is an ordinary module', async () => { + const { cg } = await project({ + 'package.json': manifest('15.5.0'), + 'app/page.tsx': page('Home'), + 'app/account/page.tsx': page('Account'), + 'app/account/[id]/page.tsx': page('AccountDetail'), + 'pages/api/health.ts': 'export default function handler(req, res) {\n res.status(200).end()\n}\n', + 'middleware.ts': + 'function guard(request) {\n return undefined\n}\n' + + 'export default guard\n' + + "export const config = { matcher: '/account/:id?' }\n", + 'proxy.ts': 'export function proxy(request) {\n return request\n}\n', + }); + expect(routesRunning(cg, fn(cg, 'guard', 'middleware.ts'))).toEqual({ '/account': '/account/:id?', '/account/:id': '/account/:id?' }); + expect(proxyEdges(cg)).toHaveLength(2); + expect(cg.getImpactRadius(fn(cg, 'proxy', 'proxy.ts').id, 3).nodes.size).toBe(1); + }); + + it('links a wrapped default export through the file, where the wrapper\'s calls are recorded', async () => { + const { cg } = await project({ + 'package.json': manifest('15.5.0'), + 'app/dashboard/page.tsx': page('Dashboard'), + 'app/page.tsx': "import Link from 'next/link'\nexport default function Home() {\n return Dashboard\n}\n", + 'lib/session.ts': 'export function isProtected(request) {\n return !!request\n}\n', + 'middleware.ts': + "import { clerkMiddleware } from '@clerk/nextjs/server'\n" + + "import { isProtected } from './lib/session'\n" + + 'export default clerkMiddleware(async (auth, req) => {\n if (isProtected(req)) await auth.protect()\n})\n' + + "export const config = { matcher: ['/((?!_next|[^?]*\\\\.(?:html?|css)).*)', '/(api|trpc)(.*)'] }\n", + }); + expect(proxyEdges(cg)).toHaveLength(2); + const edge = proxyEdges(cg).find((e) => cg.getNode(e.source)!.name === '/dashboard'); + const target = cg.getNode(edge!.target)!; + expect(target.kind).toBe('file'); + expect(target.filePath).toBe('middleware.ts'); + expect(meta(edge!).registeredAt).toBe('middleware.ts:6'); + expect(cg.getCallees(target.id).map((c) => c.node.name)).toContain('isProtected'); + + const flow = resolveNamedSymbolFlow(cg, 'Home isProtected'); + expect(flow.chains[0]?.steps.map((step) => step.node.name)).toEqual(['Home', '/dashboard', 'middleware.ts', 'isProtected']); + }); + + it('runs a proxy with no matcher on every route, pages and endpoints alike', async () => { + const { cg } = await project({ + 'package.json': manifest('16.0.0'), + 'app/page.tsx': page('Home'), + 'app/users/[id]/page.tsx': page('User'), + 'app/api/users/route.ts': 'export async function GET() {\n return Response.json([])\n}\n', + 'proxy.ts': 'export const proxy = (request) => {\n return undefined\n}\n', + }); + expect(routesRunning(cg, fn(cg, 'proxy', 'proxy.ts'))).toEqual({ '/': null, '/users/:id': null, 'GET /api/users': null }); + expect(proxyEdges(cg)[0]!.metadata).toEqual({ synthesizedBy: 'next-proxy', registeredAt: 'proxy.ts:1' }); + }); + + it('links nothing, not every route, for a matcher it can\'t read', async () => { + const unknown = await project({ + 'package.json': manifest('16.0.0'), + 'app/page.tsx': page('Home'), + 'proxy.ts': "export function proxy(request) {\n return undefined\n}\nexport const config = { matcher: ['/(\\\\d+)'] }\n", + }); + expect(proxyEdges(unknown.cg)).toEqual([]); + const variable = await project({ + 'package.json': manifest('16.0.0'), + 'app/page.tsx': page('Home'), + 'proxy.ts': "const PROTECTED = ['/']\nexport function proxy(request) {\n return undefined\n}\nexport const config = { matcher: PROTECTED }\n", + }); + expect(proxyEdges(variable.cg)).toEqual([]); + }); + + it('takes no proxy.ts outside a Next app, or in an app pinned before Next 16', async () => { + const tool = await project({ + 'package.json': JSON.stringify({ name: 'tool', dependencies: { commander: '14' } }), + 'src/mcp/proxy.ts': 'export function proxy(request) {\n return request\n}\n', + 'src/proxy.ts': 'export function proxy(request) {\n return request\n}\n', + 'src/app/page.tsx': page('Home'), + }); + expect(proxyEdges(tool.cg)).toEqual([]); + + const pinned = await project({ + 'package.json': manifest('~15.3.0'), + 'src/app/page.tsx': page('Home'), + 'src/proxy.ts': 'export function proxy(target) {\n return fetch(target)\n}\n', + }); + expect(pinned.cg.getNodesByKind('route').map((r) => r.name)).toEqual(['/']); + expect(proxyEdges(pinned.cg)).toEqual([]); + }); + + it('keeps each app of a monorepo to its own proxy', async () => { + const { cg } = await project({ + 'package.json': JSON.stringify({ name: 'repo', private: true, workspaces: ['apps/*'] }), + 'apps/shop/package.json': manifest('16.1.0'), + 'apps/shop/app/account/page.tsx': page('ShopAccount'), + 'apps/shop/proxy.ts': "export function proxy(request) {\n return undefined\n}\nexport const config = { matcher: '/account' }\n", + 'apps/blog/package.json': manifest('16.1.0'), + 'apps/blog/app/account/page.tsx': page('BlogAccount'), + }); + const edges = proxyEdges(cg); + expect(edges.map((e) => cg.getNode(e.source)!.filePath)).toEqual(['apps/shop/app/account/page.tsx']); + expect(cg.getNode(edges[0]!.target)!.filePath).toBe('apps/shop/proxy.ts'); + }); + + it('follows a sync: an edited matcher, a new route under it, and the proxy going away', async () => { + const { cg, dir } = await project({ + 'package.json': manifest('16.0.0'), + 'app/page.tsx': page('Home'), + 'app/admin/page.tsx': page('Admin'), + 'proxy.ts': "export function proxy(request) {\n return undefined\n}\nexport const config = { matcher: '/nowhere' }\n", + }); + const names = (): string[] => proxyEdges(cg).map((e) => cg.getNode(e.source)!.name).sort(); + expect(names()).toEqual([]); + + write(dir, 'proxy.ts', "export function proxy(request) {\n return undefined\n}\nexport const config = { matcher: '/admin/:rest*' }\n"); + await cg.sync(); + expect(names()).toEqual(['/admin']); + + write(dir, 'app/admin/api/route.ts', 'export async function DELETE() {\n return new Response(null)\n}\n'); + await cg.sync(); + expect(names()).toEqual(['/admin', 'DELETE /admin/api']); + + fs.rmSync(path.join(dir, 'proxy.ts')); + await cg.sync(); + expect(names()).toEqual([]); + }); +}); diff --git a/__tests__/nextjs.test.ts b/__tests__/nextjs.test.ts index b010ffd06..ed80610d6 100644 --- a/__tests__/nextjs.test.ts +++ b/__tests__/nextjs.test.ts @@ -219,7 +219,8 @@ describe('nextjs: end to end', () => { it('names every page and endpoint, and binds a page to its component and an endpoint to its function', () => { expect(cg.getNodesByKind('route').map((r) => r.name).sort()).toEqual(['/', '/login', '/users', '/users/:id', 'GET /api/users', 'POST /api/users']); - const home = cg.getOutgoingEdges(route('/').id).find((e) => e.kind === 'calls'); + // The middleware the route runs first is a `calls` edge too (`nextProxyEdges`). + const home = cg.getOutgoingEdges(route('/').id).find((e) => e.kind === 'calls' && (e.metadata as Record).synthesizedBy !== 'next-proxy'); expect(cg.getNode(home!.target)?.name).toBe('Home'); const post = cg.getOutgoingEdges(route('POST /api/users').id).find((e) => e.kind === 'references'); expect(cg.getNode(post!.target)).toMatchObject({ name: 'POST', kind: 'function', filePath: 'app/api/users/route.ts' }); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 9005fbbd3..2f567c3b9 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -37,7 +37,7 @@ guessed. | Router | Resolver | Markup synthesizer | Tests | Validated on | |---|---|---|---|---| | Expo Router | `frameworks/expo-router.ts` | `expo-router-synthesizer.ts` | `expo-router.test.ts` | — | -| Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter | +| Next.js | `frameworks/nextjs.ts`, `frameworks/next-proxy.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts`, `nextjs-proxy.test.ts` | next-saas-starter; the proxy (trap 22): next-saas-starter (8 routes), taxonomy (6), ai-chatbot (19), dub's web app (80) | | React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 ``), bulletproof-react (nested `children`, `lazy` routes, `paths.x.path` constants through an app's own tsconfig alias: 0 → 9 named, linked routes), and its `paths.x.getHref(id)` links and `navigate(paths.x.getHref())` calls (0 → 11 navigates). Route tables another file hands the router (`tableRoutes` in `frameworks/react.ts`, trap 13): the ASP.NET Core React template's `AppRoutes.map(… )` on jasontaylordev/CleanArchitecture's `ClientApp-React` (0 → 6 routes, 11 navigates), replaysMike/Binner (0 → 46, 87), GavinLonDigital/RankingApp, GarrettHays/PaydirtPickem, charlessolar/eShopOnContainersDDD; `useRoutes(routes)` from another file and `[...sessionRoutes]` on uilibrary/matx-react (0 → 7, 16), and its `[...materialRoutes]` once the jsconfig `"baseUrl": "src"` import naming it resolved (7 → 21 routes, each bound through `lazy(() => import(…))` to its page, trap 20); codedthemes' `createBrowserRouter([MainRoutes, LoginRoutes])` on berry and mantis (9 and 8 routes, no navigation they can resolve; each bound to its lazily loaded page and its layout since their `baseUrl` / `"*"` imports resolve, where before they named the local `Loadable(lazy(…))` value); a table `.map`ped inside `` on RADeveloping/chickadeeinvest; `useRoutes([...])` in place on minimal-ui-kit/material-kit-react (0 → 7, bound through `lazy(() => import(…))` to each page). A table a function returns, handed over through a variable (`const routes = getRouteMap({ … })` in a class's `render()`, then `createBrowserRouter(routes)`; trap 13): Medusa's admin dashboard (0 → 221 routes, each bound to its page, 613 layout edges, 92 navigations newly linked: 60 `navigate()` calls and 32 ``s; all 821 edges an AST oracle on the TypeScript compiler expects, and no others), Mercur's admin panel (0 → 216; 775 of 775), serenedb's serene-ui (`buildRoutes = () => [...appRoutes, ...loaderRoutes]`: 0 → 4), 22 control repos byte-identical, and a late-file sync equal to a fresh index on both dashboards. Route objects that render `` (`element: `, `Component: Outlet`, a `lazy` loader handing back `{ Component: Outlet }`; trap 14): both dashboards and mercurjs/vendor-panel drop every `layout:Outlet` reference they made (93, 106 and 87 rows, none of which linked anything) and the 13, 5 and 5 layout edges that drew a product's detail page around its variants' pages; React Router's error-boundaries example's `/` becomes its `Layout` page instead of React Router's own `Outlet`; 21 other repos byte-identical, and 2,677 surveyed router files change only by dropping 40 such references. A table behind `useMemo` (trap 13): anotherIlnaz/tg-tt-bot-stage (`useMemo(() => getRoutes(personalAcc), [personalAcc])`: 0 → 12 routes, 16 navigations), dingtalkwukong/bewild-clone (`useMemo(() => buildRoutes(), [])`: 0 → 13, 4) and the-ctrlv/SpaceR (`useMemo(getRoutes, [])` through a `baseUrl` import: 0 → 10, 21), each route bound to the page its table names; EyeSeeTea/training-app, whose builder maps a config into routes, and BANKFORM1, whose route file imports `createBrowserRouter` and so was read already, unchanged, with 25 other repos byte-identical. JSX index routes and layouts (trap 14): proshop-v2's `createRoutesFromElements` (`/` is HomeScreen inside App, not App; 30 layout edges, the `path=''` guards included; Screens 30 → 74 transitions, the guards' redirects drawn from the screens they guard), chickadeeinvest's table inside `}>` (its 4 routes get the layout), React Router 6.29's own examples and tests, refine's 281 examples (one `/` per app, 191 layout edges, 21 new navigations), crwn-clothing, the vanlife course app, volun-mern (105 layout edges, six levels deep), spotify/vispana, react_persist_login, chushi (`path={"agents"}`: 2 → 6 routes), hathor-explorer (`path=""` at the root), cboard, react-router-auth-v6: 578 of 581 new layout edges name a component of an enclosing `` (vanlife's 3 inherit a default-import resolution bug that already misbinds its pages), 27 of 27 new navigations precise, no navigation lost, 16 control repos byte-identical. Lazy route modules that are barrels (`exportedComponent` in `frameworks/react.ts`): luci-go's milo/ui (`export * from './x'` index files and `export { X as Component }` clauses: 0 → 51 lazy routes linked; the other 40 load a file under `src/build/`, which the default ignore skips, export only a `loader` or a `handle`, or pick a named export in an async `lazy`), Medusa's admin dashboard (all 193 route-map modules resolve through its `export { X as Component } from` barrels), four more apps (9 routes), 16 control repos byte-identical. Lazy loaders that pick their page (`lazyRouteReference` in `frameworks/react.ts`, trap 18): milo/ui's three tabs (`const { TestTab } = await import('…/tabs')`), 20 apps from a GitHub survey of `createBrowserRouter` + `lazy` (636 picked-page edges, each checked against the target file's own export: 467 by name, 169 as its `export default`; 42 routes written with `async lazy()` now indexed, among them React Router's own `lazy-loading-router-provider` example), an AST oracle on the TypeScript compiler agreeing with the text reader on all 2,538 loaders of 876 surveyed router files, a late-file sync equal to a fresh index on 8 of those apps, and 26 control repos byte-identical, among them ghostfolio, whose 30 Angular lazy routes stay Angular's with React detected in the same project. Barrels that pass on a page they import (`import { UserLayout } from './UserLayout'; export { UserLayout }`, `import Login from './Login'; export default Login`; `exportedComponent` follows the import): Arshia76/DigiShop.Frontend (0 → 27 route and layout edges), 49 other repos byte-identical; `export default withAuth(Page)` is followed nowhere. A page in a folder below the barrel's (`pages/Home/index.tsx` behind `@/pages`) whose route failed waits under the barrel's module key (`module:pages`), which the page's own keys never give: a sync that adds the page, or gives it a definition, walks up the import edges to the barrels that pass it on (`reopenReferencesThroughBarrels` in `resolution/module-importers.ts`) and re-opens what waits under theirs. A late-file sync on DigiShop then equals a fresh index (13 of its routes stayed unlinked before), and 27 other repos sync exactly as before. A name the route file declares itself (trap 19), such as a lazy page or layout, a `Loadable(lazy(…))` or an `observer(…)` value, binds to what that declaration renders wherever the file uses it, in routes, layouts and JSX alike: outline (+31 edges, 7 moved off another file's namesake or onto the screen a lazy declaration loads), mastodon (+20, 9 moved: `lazy(() => import(…).then(({ Emoji }) => …))` values and `let Emoji` locals had gone to other files' components), SigNoz (+7, 4 moved), excalidraw (+3, 1 moved: the Next.js example's `` was the docs site's), bulletproof-react (+6), material-kit-react and create-t3-turbo (+1 each), with matx, mantis, proshop, next-saas-starter and berry byte-identical | | TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend | | Vue Router / Nuxt | `frameworks/vue-router.ts` (Nuxt file routes: `nuxtResolver` in `frameworks/vue.ts`) | `vue-router-synthesizer.ts` | `vue-router.test.ts`, `nuxt-navigate-to.test.ts` | vue-realworld (23 edges); vue-element-admin (62 routes), vue-admin-template (14), vben (192), halo console (34) — named tables, module files, `children` + layouts; Nuxt: mealie, elk, nuxt/movies. Nuxt's `navigateTo` (trap 21): mealie 13 of its 14 calls, nuxt.com 17 of 24, elk 9 of 10 outside the plugin that declares its own, atidone 2 of 2, pages and route middleware alike; the rest are computed, an API endpoint (`{ external: true }`), or a page of a Nuxt layer (`extends: ['./layers/nuxi']`), whose pages sit in a route table of their own. nuxt/ui's docs: 0 of 4, two computed and two whose only page is a catch-all (`[...slug].vue`, named `*slug`) or an optional parameter (`[[tag]].vue`, `:tag?`), which `matchRoute` doesn't read yet, for markup links either | @@ -450,6 +450,27 @@ Each of these cost real debugging time; they are not hypothetical. sync binds it when its page appears. An absolute URL is no path of the app: Vue Router's call reader keeps only destinations that start with `/`, as its markup reader does. +22. **What runs before a route has no import to it.** Next wires its proxy + (`proxy.ts`, `middleware.ts` before 16) by file name and `config.matcher`, + so `nextProxyEdges` draws a `calls` edge from each route the matcher accepts + to what runs there. `calls` because that is what `callers` and Flow ride: a + route stands for a request arriving, and the proxy is the first thing that + request runs. `decorates` reads as well, and leaves the proxy with no callers + and every flow stopped at the route. `routeRoots` skips these edges, since + they don't say where the route's own code starts, and so does the Screens + walk (`arrivesFromAnotherContext`): walked backwards from a redirect in the + proxy, 30-odd routes are more callers than it takes, and the navigation was + dropped. Screens stays byte-identical on all four apps. + Two things decide whether this reaches real apps at all. About two thirds of + real matchers are a negative lookahead (`/((?!api|_next/static).*)`), which a + reader of literal paths links nothing from, and about a third of real + proxies are a wrapped default export (`export default clerkMiddleware(…)`), + which has no node, so the edge goes to the file, where the wrapper's calls + are recorded. `compileMatcher` builds its regular expression from the parts + it recognizes and never from the entry's text: `new RegExp()` on a pattern + in an indexed repository could hang the index. Checked against path-to-regexp + on the 344 entries of 157 real proxies: 339 read, all agreeing on 27 + addresses, and 5 left unread, which link nothing. --- diff --git a/src/graph/synthesized-hop.ts b/src/graph/synthesized-hop.ts index aa68ee979..0344b851e 100644 --- a/src/graph/synthesized-hop.ts +++ b/src/graph/synthesized-hop.ts @@ -75,6 +75,8 @@ export function describeSynthesizedHop(edge: Edge | null | undefined): Synthesiz return hop(`fn-pointer ${m.via ? String(m.via) : ''}`); case 'goframe-route': return hop(`GoFrame route ${m.route ? String(m.route) : ''}`); + case 'next-proxy': + return hop(m.matcher ? `Next proxy, matcher ${quoted(m.matcher, '')}` : 'Next proxy, on every request'); case 'go-func-field': // Go: a call through a struct field holding a function, `d.refreshf(ctx)`; // `via` is the field it was copied from, which the wiring site writes. diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index db842d158..6975bdaaf 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -31,7 +31,9 @@ import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer'; import { goframeRouteEdges } from './goframe-synthesizer'; import { goFuncFieldEdges } from './go-func-field-synthesizer'; import { expoRouterReturnEdges } from './expo-router-synthesizer'; -import { nextLinkEdges } from './next-router-synthesizer'; +import { nextLinkEdges, nextProxyEdges } from './next-router-synthesizer'; +import { nextRouteForFile } from './frameworks/nextjs'; +import { NEXT_PROXY_FILE } from './frameworks/next-proxy'; import { reactRouterLinkEdges } from './react-router-synthesizer'; import { tanstackLinkEdges } from './tanstack-router-synthesizer'; import { vueRouterLinkEdges } from './vue-router-synthesizer'; @@ -5103,6 +5105,8 @@ export function hasSynthesisPattern(filePath: string, content: string): boolean // These passes consume declarations/layouts as well as dispatch sites. A // header or markup edit can change a channel whose endpoints live elsewhere. if (/\.(?:vue|svelte|dfm|fmx|nix|xml)$/.test(filePath)) return true; + // A Next proxy and the routes its matcher covers are joined by path and config, not by any line of code. + if (NEXT_PROXY_FILE.test(filePath) || nextRouteForFile(filePath) !== null) return true; if (/\.(?:c|h|cc|cpp|cxx|hpp|hh|hxx|cppm|ipp|inl|tcc|def|inc|tbl)$/i.test(filePath) && /\b(?:struct|union|typedef|virtual|override)\b|#\s*(?:include|define|if)|=|->|\[/.test(content)) return true; if (/\b(?:class|interface|protocol|trait|impl|extends|implements|expect|actual)\b/.test(content)) return true; @@ -5214,6 +5218,8 @@ export const SYNTH_PASSES: SynthPassDef[] = [ { name: 'expoRouterReturnEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => expoRouterReturnEdges(c, y) }, // `` / an internal `` — markup, not a call; the component navigates. { name: 'nextLinkEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => nextLinkEdges(c, y) }, + // `proxy.ts` / `middleware.ts` — every route its `config.matcher` accepts runs it first. + { name: 'nextProxyEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => nextProxyEdges(c, y) }, { name: 'reactRouterLinkEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => reactRouterLinkEdges(c, y) }, { name: 'tanstackLinkEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => tanstackLinkEdges(c, y) }, { name: 'vueRouterLinkEdges', gate: (has) => has('vue', ...JS_FAMILY), run: (_q, c, y) => vueRouterLinkEdges(c, y) }, diff --git a/src/resolution/frameworks/next-proxy.ts b/src/resolution/frameworks/next-proxy.ts new file mode 100644 index 000000000..fcb409e9f --- /dev/null +++ b/src/resolution/frameworks/next-proxy.ts @@ -0,0 +1,324 @@ +/** + * Next.js proxy (`proxy.ts`, `middleware.ts` before Next 16): the one file of an + * app nothing imports. It sits beside `app` / `pages` and runs before every + * route its `config.matcher` accepts, so to the graph it was a function nobody + * calls and a change to it reached no route. + * + * This file says which file Next loads for an app, what runs in it, and what + * its matcher accepts. `nextProxyEdges` in `next-router-synthesizer.ts` draws + * the edges. + */ + +import type { Node } from '../../types'; +import type { ResolutionContext } from '../types'; +import { stripCommentsForRegex } from '../strip-comments'; +import { HOLE, readStringAt } from './expo-router'; +import { matchBracket, readFields, skipString } from './object-literal'; + +const PROXY_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs']; + +/** A file named like a proxy. Whether Next loads it depends on where it sits (`nextProxyDir`). */ +export const NEXT_PROXY_FILE = /(?:^|\/)(?:proxy|middleware)\.(?:tsx?|jsx?|mjs|cjs)$/; + +const ENTRY_KINDS: ReadonlySet = new Set(['function', 'constant', 'variable']); + +/** + * The directory an app's proxy must sit in: beside `pages` when the app has + * one, else beside `app`, each looked for at the app's root before its `src/`, + * which is `next build`'s `join(pagesDir || appDir, '..')`. A `proxy.ts` + * anywhere else is an ordinary module. + */ +export function nextProxyDir(root: string, routeFiles: readonly string[]): string { + for (const dir of ['pages/', 'app/']) { + if (routeFiles.some((file) => file.startsWith(root + dir))) return root; + if (routeFiles.some((file) => file.startsWith(`${root}src/${dir}`))) return `${root}src/`; + } + return root; +} + +/** Whether the app's manifest, or failing that the repository's, pins `next` below 16, where `proxy` isn't a convention yet. */ +function pinsNextBefore16(context: ResolutionContext, root: string): boolean { + for (const manifest of new Set([`${root}package.json`, 'package.json'])) { + let pkg: Record | undefined> | null; + try { + pkg = JSON.parse(context.readFile(manifest) ?? 'null'); + } catch { + continue; + } + const range = pkg?.dependencies?.next ?? pkg?.devDependencies?.next ?? pkg?.peerDependencies?.next; + if (typeof range !== 'string') continue; + const major = /^[\s^~>=v]*(\d+)/.exec(range); + return major !== null && Number(major[1]) < 16; + } + return false; +} + +export interface NextProxy { + file: string; + /** + * What runs on a matched request: the export named after the file, else the + * default export (Next's `mod.proxy || mod.default`), else the file itself. + * `export default clerkMiddleware(async (auth, req) => …)` and `export { auth + * as middleware } from '@/auth'` have no node of their own, and the file is + * where a wrapper's calls are recorded. + */ + entry: Node; +} + +/** + * The proxy Next runs for an app, or null. `middleware.*` wins when both files + * exist: Next 16 refuses to build with both, and before 16 a `proxy.ts` is an + * ordinary module, so a lone one counts unless the app pins an older Next. + */ +export function nextProxyFor(context: ResolutionContext, dir: string, root: string): NextProxy | null { + const find = (base: string): string | undefined => + PROXY_EXTENSIONS.map((ext) => `${dir}${base}.${ext}`).find((file) => context.fileExists(file)); + const middleware = find('middleware'); + const proxy = middleware ? undefined : find('proxy'); + const file = middleware ?? (proxy && !pinsNextBefore16(context, root) ? proxy : undefined); + if (!file) return null; + + const nodes = context.getNodesInFile(file); + const named = nodes.find((n) => ENTRY_KINDS.has(n.kind) && n.isExported && n.name === (middleware ? 'middleware' : 'proxy')); + if (named) return { file, entry: named }; + + const safe = stripCommentsForRegex(context.readFile(file) ?? '', 'typescript'); + const local = + /\bexport\s+default\s+(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/.exec(safe)?.[1] ?? + /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?\s*$/m.exec(safe)?.[1] ?? + /\bexport\s*\{[^}]*?\b([A-Za-z_$][\w$]*)\s+as\s+(?:default|middleware|proxy)\b[^}]*\}(?!\s*from\b)/.exec(safe)?.[1]; + const entry = (local ? nodes.find((n) => ENTRY_KINDS.has(n.kind) && n.name === local) : undefined) ?? nodes.find((n) => n.kind === 'file'); + return entry ? { file, entry } : null; +} + +// ============================================================================= +// config.matcher +// ============================================================================= + +/** One path a proxy's matcher names, and the offset it is written at. */ +export interface ProxyMatcherEntry { + source: string; + index: number; +} + +/** + * The paths `export const config = { matcher }` names, or `'all'` when the file + * sets no matcher, where Next runs the proxy on every request. + * + * Read: a string, or an array of strings and `{ source }` objects (`has` and + * `missing` only narrow which requests to a covered path run it). Next reads + * `config` statically and refuses anything else, a variable, a spread, a + * template with holes, so such a matcher reads as `[]`, never as `'all'`. + */ +export function readProxyMatcher(safe: string): ProxyMatcherEntry[] | 'all' { + const decl = /\bexport\s+const\s+config\b[^=;]*=\s*/.exec(safe); + if (!decl) return 'all'; + const open = decl.index + decl[0].length; + const close = safe[open] === '{' ? matchBracket(safe, open) : -1; + if (close < 0) return []; + const field = readFields(safe, open, close).get('matcher'); + // A shorthand or a quoted key is a matcher this can't see into. + if (!field) return /\bmatcher\b/.test(safe.slice(open, close)) ? [] : 'all'; + const at = valueAfterKey(safe, field.at); + if (safe[at] !== '[') { + const one = stringEntry(safe, at); + return one ? [one] : []; + } + const end = matchBracket(safe, at); + const entries: ProxyMatcherEntry[] = []; + for (let i = at + 1; i > 0 && i < end; ) { + const ch = safe[i]!; + if (/[\s,]/.test(ch)) { + i++; + continue; + } + const entry = ch === '{' ? sourceEntry(safe, i) : stringEntry(safe, i); + if (!entry) return []; + entries.push(entry); + i = afterElement(safe, i, end); + } + return entries; +} + +function sourceEntry(s: string, open: number): ProxyMatcherEntry | null { + const source = readFields(s, open, matchBracket(s, open)).get('source'); + return source ? stringEntry(s, valueAfterKey(s, source.at)) : null; +} + +function stringEntry(s: string, at: number): ProxyMatcherEntry | null { + const source = readStringAt(s, at); + return source === null || source.includes(HOLE) ? null : { source, index: at }; +} + +/** The offset of a field's value, from the offset of its key. */ +function valueAfterKey(s: string, keyAt: number): number { + return keyAt + (/^[A-Za-z_$][\w$]*\s*:\s*/.exec(s.slice(keyAt, keyAt + 128))?.[0].length ?? 0); +} + +/** The offset of the comma that ends the array element at `at`, or of the array's end. */ +function afterElement(s: string, at: number, end: number): number { + let i = at; + while (i > 0 && i < end && s[i] !== ',') { + const c = s[i]!; + if (c === '"' || c === "'" || c === '`') i = skipString(s, i) + 1; + else if (c === '{' || c === '[' || c === '(') i = matchBracket(s, i) + 1; + else i++; + } + return i; +} + +/** + * Repeating parts one entry may carry, and how long it may be. Each repeat + * multiplies the work of a match, and a pattern in a repository being indexed + * is not trusted to stay small. Two takes every real matcher in the survey. + */ +const MAX_REPEATS = 2; +const MAX_MATCHER_LENGTH = 2048; + +/** A route parameter's stand-in in a sample address: no literal in a matcher can equal it. */ +const ANY_SEGMENT = '\u0001'; + +/** What every pattern for a file with an extension reads as: a dot in the last segment. */ +const FILE_WITH_EXTENSION = '.*\\.[^/]*$'; + +const escapeRegex = (text: string): string => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); + +/** The offset of the `)` closing the `(` at `open`, or -1. */ +function closingParen(s: string, open: number): number { + let depth = 0; + for (let i = open; i < s.length; i++) { + const c = s[i]; + if (c === '\\') i++; + else if (c === '[') { + while (i < s.length && s[i] !== ']') i += s[i] === '\\' ? 2 : 1; + } else if (c === '(') depth++; + else if (c === ')' && --depth === 0) return i; + } + return -1; +} + +/** `a|b|(c|d)` split at its top-level bars. */ +function topLevelBars(s: string): string[] { + const out: string[] = []; + let from = 0; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '\\') i++; + else if (c === '[') { + while (i < s.length && s[i] !== ']') i += s[i] === '\\' ? 2 : 1; + } else if (c === '(') { + const close = closingParen(s, i); + if (close < 0) return [s]; + i = close; + } else if (c === '|') { + out.push(s.slice(from, i)); + from = i + 1; + } + } + out.push(s.slice(from)); + return out; +} + +/** + * One branch of a negative lookahead, as a regex of our own: a literal path + * prefix (`api`, `_next/static`, `favicon.ico`, `api(?:/|$)`) or a pattern for + * files with an extension (`[^?]*\.(?:html?|css)`, `.*\.(?:svg|png)$`, + * `.*\..*`). Every such pattern reads the same way: a route with a dot in its + * last segment is left out, the one case a page path could meet them. The dot + * has to be escaped: `'.*\..*'` in a JavaScript string arrives as `.*..*`, which + * takes every path, so that app's proxy really does skip its pages. + */ +function readExclusion(branch: string): string | null { + if (/^(?:\[\^\?\]\*|\.\*|\.\+|\[[^\]]*\]\+)\\\./.test(branch)) return FILE_WITH_EXTENSION; + const literal = /^((?:[\w/~-]|\\?\.)+)(\$|\(\?:\/\|\$\))?$/.exec(branch); + if (!literal) return null; + return escapeRegex(literal[1]!.replace(/\\\./g, '.')) + (literal[2] ?? ''); +} + +/** + * The inside of a matcher's group: `.*`, `.+`, `a|b` of literals, or + * `(?!a|b|….*\.png$).*`, the form almost every real matcher takes. + */ +function readGroup(body: string): string | null { + if (body === '.*' || body === '.+') return body; + if (body.startsWith('(?!')) { + const close = closingParen(body, 0); + const tail = close < 0 ? '' : body.slice(close + 1); + if (tail !== '.*' && tail !== '.+') return null; + const branches = topLevelBars(body.slice(3, close)).map(readExclusion); + return branches.includes(null) ? null : `(?!${[...new Set(branches)].join('|')})${tail}`; + } + const options = topLevelBars(body.replace(/^\?:/, '')); + if (!options.every((option) => /^[\w./~-]+$/.test(option))) return null; + return `(?:${options.map(escapeRegex).join('|')})`; +} + +/** + * A matcher entry as a regular expression, or null for one this doesn't read, + * which links nothing rather than a guess. + * + * Next compiles an entry with path-to-regexp: anchored at both ends, so `/about` + * is that page alone and `/about/:path*` is it and everything under it. The + * expression is built here from the entry's parts, and never from its text: a + * matcher is the indexed repository's to write, and `new RegExp()` on it would + * let one hang an index. + */ +export function compileMatcher(source: string): RegExp | null { + if (!source.startsWith('/') || source.length > MAX_MATCHER_LENGTH) return null; + let out = ''; + for (let i = 0; i < source.length; ) { + const param = /^\/:[A-Za-z_]\w*/.exec(source.slice(i)); + if (param) { + i += param[0].length; + let body = '[^/]+'; + if (source[i] === '(') { + const close = closingParen(source, i); + const group = close < 0 ? null : readGroup(source.slice(i + 1, close)); + if (group === null) return null; + body = group; + i = close + 1; + } + const modifier = /[?*+]/.test(source[i] ?? '') ? source[i++] : ''; + out += modifier ? `(?:/${body})${modifier}` : `/${body}`; + continue; + } + const ch = source[i]!; + if (ch === '(') { + const close = closingParen(source, i); + const group = close < 0 ? null : readGroup(source.slice(i + 1, close)); + if (group === null || /[?*+]/.test(source[close + 1] ?? '')) return null; + out += group; + i = close + 1; + continue; + } + if (!/[\w./~-]/.test(ch)) return null; + out += escapeRegex(ch); + i++; + } + if ((out.split(FILE_WITH_EXTENSION).join('').match(/\.[*+]|\)[*+?]/g)?.length ?? 0) > MAX_REPEATS) return null; + // `/?`: path-to-regexp's trailing delimiter, which is how `/:path*` takes `/`. + return new RegExp(`^${out}/?$`); +} + +/** + * Addresses a route serves, one of each shape: a parameter is a segment no + * literal stands for, so `/blog/featured` doesn't cover `/blog/[slug]` (a + * `blog/featured` page would own that address), and the route's name writes + * `[...all]` and `[[...all]]` alike as `:all*`, so the file says which may be + * empty. + */ +function routeAddresses(path: string, routeFile: string): string[] { + const segments = path.split('/').filter(Boolean); + const filled = segments.map((segment) => (segment.startsWith(':') ? ANY_SEGMENT : segment)); + const addresses = [`/${filled.join('/')}`]; + const last = segments[segments.length - 1]; + if (last?.endsWith('*') && routeFile.includes(`[[...${last.slice(1, -1)}]]`)) { + addresses.push(`/${filled.slice(0, -1).join('/')}`); + } + return addresses; +} + +/** Whether a compiled matcher entry accepts an address the route serves. */ +export function matcherCovers(matcher: RegExp, routePath: string, routeFile: string): boolean { + return routeAddresses(routePath, routeFile).some((address) => matcher.test(address)); +} diff --git a/src/resolution/next-router-synthesizer.ts b/src/resolution/next-router-synthesizer.ts index 026abacf7..79bc95e25 100644 --- a/src/resolution/next-router-synthesizer.ts +++ b/src/resolution/next-router-synthesizer.ts @@ -19,15 +19,26 @@ * href as written and `registeredAt` = the JSX site. A computed href * (`href={href}`) is nothing; a path no page serves is nothing. Nothing here * runs on a project with no Next pages. + * + * `nextProxyEdges` does the same for the proxy (`proxy.ts`, `middleware.ts` + * before Next 16), which Next wires by file name and `config.matcher`, never by + * an import: each route the matcher accepts gets a `calls` edge to what runs + * in the proxy, `synthesizedBy:'next-proxy'`. A route stands for a request + * arriving at that address, and that request runs the proxy first, so the + * proxy's callers are the routes it runs before and a flow from a route reaches + * what the proxy calls. `routeRoots` and the Screens walk leave these edges out: + * they say what runs before the route's code, not where it starts, and no route + * called the proxy. */ -import type { Edge } from '../types'; +import type { Edge, Node } from '../types'; import type { ResolutionContext } from './types'; import type { MaybeYield } from './cooperative-yield'; import { stripCommentsForRegex } from './strip-comments'; import { isTestPath } from '../search/query-utils'; import { readStringAt, toHref } from './frameworks/expo-router'; -import { nextRouteTable, destinationsForHref } from './frameworks/nextjs'; +import { nextRouteTable, destinationsForHref, nextjsResolver, nextRouteForFile } from './frameworks/nextjs'; +import { compileMatcher, matcherCovers, nextProxyDir, nextProxyFor, readProxyMatcher } from './frameworks/next-proxy'; import { enclosingFn, makeLineAt } from './synth-utils'; const JSX_FILE = /\.(?:[cm]?[jt]sx?|mdx)$/; @@ -102,3 +113,53 @@ export async function nextLinkEdges(ctx: ResolutionContext, onYield: MaybeYield) } return edges; } + +/** + * Every route a Next proxy runs before, linked to what runs in it, app by app: + * each app's proxy is looked for only where Next loads it. With no `matcher` + * it runs on every request; with one, a route is linked when some entry accepts + * an address it serves, and an entry this can't read links nothing. The edge + * carries the entry that matched, and `registeredAt` is where that entry is + * written. + */ +export async function nextProxyEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise { + const apps = new Map(); + for (const route of ctx.getNodesByKind('route')) { + const file = nextRouteForFile(route.filePath); + if (!file) continue; + // A page is named by its path, an endpoint by its method and path. + if (file.kind === 'page' ? route.name !== file.path : !route.name.endsWith(` ${file.path}`)) continue; + const list = apps.get(file.root) ?? []; + list.push({ route, path: file.path }); + apps.set(file.root, list); + } + if (apps.size === 0 || !nextjsResolver.detect(ctx)) return []; + + const edges: Edge[] = []; + for (const [root, routes] of apps) { + await onYield(); + const proxy = nextProxyFor(ctx, nextProxyDir(root, routes.map((r) => r.route.filePath)), root); + if (!proxy) continue; + const safe = stripCommentsForRegex(ctx.readFile(proxy.file) ?? '', 'typescript'); + const matcher = readProxyMatcher(safe); + const entries = matcher === 'all' ? null : matcher.map((m) => ({ ...m, regex: compileMatcher(m.source) })); + const lineOf = makeLineAt(safe, 1); + for (const { route, path } of routes) { + const hit = entries?.find((e) => e.regex !== null && matcherCovers(e.regex, path, route.filePath)); + if (entries && !hit) continue; + edges.push({ + source: route.id, + target: proxy.entry.id, + kind: 'calls', + line: route.startLine, + provenance: 'heuristic', + metadata: { + synthesizedBy: 'next-proxy', + ...(hit ? { matcher: hit.source } : {}), + registeredAt: `${proxy.file}:${hit ? lineOf(hit.index) : proxy.entry.startLine}`, + }, + }); + } + } + return edges; +} diff --git a/src/ui-server/api/route-roots.ts b/src/ui-server/api/route-roots.ts index f9ab4c129..cc7b29a71 100644 --- a/src/ui-server/api/route-roots.ts +++ b/src/ui-server/api/route-roots.ts @@ -79,6 +79,9 @@ export function routeRoots(cg: CodeGraph, routes: readonly Node[]): Map e.target)); const byRoute = new Map(); for (const e of edges) { + // The proxy a Next route passes through runs before the route's code; its + // `calls` edge says nothing about where that code starts (`nextProxyEdges`). + if ((e.metadata as Record | undefined)?.synthesizedBy === 'next-proxy') continue; const list = byRoute.get(e.source) ?? []; list.push(e); byRoute.set(e.source, list); diff --git a/src/ui-server/api/screens.ts b/src/ui-server/api/screens.ts index 6d15b56fb..948754435 100644 --- a/src/ui-server/api/screens.ts +++ b/src/ui-server/api/screens.ts @@ -183,7 +183,9 @@ function arrivesFromAnotherContext(edge: Edge): boolean { // The cross-tier synthesizer marks every edge it makes with the tier it // crossed (a client's fetch onto its route, a queue job onto its consumer). if (meta.tier !== undefined) return true; - return meta.synthesizedBy === 'rn-event-channel'; + // A Next proxy runs before every route it covers, and no route called it: a + // redirect written in the proxy is on no screen. + return meta.synthesizedBy === 'rn-event-channel' || meta.synthesizedBy === 'next-proxy'; } /** A component rendered by at least this many screens is chrome, not a screen's own behaviour. */