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. */