mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 16:52:09 +08:00
feat(nextjs): a route links to the proxy that runs before it (#3119)
Next wires proxy.ts (middleware.ts before Next 16) by file name and
config.matcher, never by an import, so to the graph it was a function
nobody calls. `codegraph impact middleware` came back with just the
function, for the one file that gates every authenticated route.
A new synthesis pass links each route the matcher accepts to what runs
in the proxy: a `calls` edge, synthesizedBy next-proxy, carrying the
matcher entry and where it is written. It looks for the proxy only where
next build loads it, beside pages or app, app by app, and takes a lone
proxy.ts only when the app doesn't pin Next below 16.
Three decisions differ from PR #2265, each from a real-repo result:
- The edge is `calls`, not `decorates`. `callers` and explore's Flow ride
calls, so with decorates the proxy has no callers and every flow stops
at the route. routeRoots and the Screens walk leave the edge out, as
they do for other edges that don't say where a route's code starts.
- Regular-expression matchers are read. In a sample of 157 proxies from
GitHub code search, about two thirds use a negative lookahead such as
/((?!api|_next/static).*), which a reader of literal paths links
nothing from. compileMatcher builds its expression from the parts it
recognizes and never from the entry's text, since new RegExp() on a
pattern in an indexed repository could hang the index.
- A wrapped default export links to the file. About a third of that
sample, which two search queries looked for, is `export default
clerkMiddleware(...)` or similar, which has
no node, and the file node is where the wrapper's calls are recorded.
Validation:
- nextjs-proxy.test.ts, 56 tests; 8 end-to-end ones fail without the
pass, and the Screens one fails without the walk change.
- Against path-to-regexp 6.3.0, Next's own matcher library: 344 entries
from the 157 proxies, 339 read, all agreeing on 27 addresses; 5 not
read, which link nothing. Route by route on the four apps below, 387
of 387 agree.
- A/B on main cd04b21a: only `+calls next-proxy`. taxonomy 298a8857 +6 of
23 routes, nextjs/saas-starter 6e33e58b +8 of 12, vercel/ai-chatbot
c2f8235e +19 of 19, dub 6b463b4b's apps/web +80 of 333. No node, ref or
other edge changes. Controls flask 49943b5a and gin 0f09c3a9 are
byte-identical. Screens is byte-identical on all four and route roots
are unchanged.
- Flow connects: saas-starter's Header -> [next link] / -> [Next proxy]
middleware -> verifyToken.
- Index CPU, median of 3 interleaved, with a suite running: ai-chatbot
-2.9%, taxonomy +3.1%, excalidraw +2.8%, all within noise.
Left out: `has` and `missing` on a matcher entry, which only narrow which
requests run the proxy; a matcher Next can't read statically, which links
nothing; dev server and basePath rewriting of addresses.
Reimplements PR #2265 by @RIGOandre.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a2870563d7
commit
446d08c272
@@ -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 `<Link href>` — 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 `<Link href>` 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<string, string>): 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<string, unknown> => (e.metadata ?? {}) as Record<string, unknown>;
|
||||
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<string, unknown> =>
|
||||
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 <Link href=\"/dashboard\">Dashboard</Link>\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 <Link href=\"/dashboard\">Dashboard</Link>\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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>).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' });
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
|
||||
@@ -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) },
|
||||
// `<Link href="/x">` / an internal `<a href>` — 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) },
|
||||
|
||||
@@ -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<Node['kind']> = 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<string, Record<string, unknown> | 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));
|
||||
}
|
||||
@@ -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<Edge[]> {
|
||||
const apps = new Map<string, { route: Node; path: string }[]>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ export function routeRoots(cg: CodeGraph, routes: readonly Node[]): Map<string,
|
||||
const targets = cg.getNodesByIds(edges.map((e) => e.target));
|
||||
const byRoute = new Map<string, typeof edges>();
|
||||
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<string, unknown> | undefined)?.synthesizedBy === 'next-proxy') continue;
|
||||
const list = byRoute.get(e.source) ?? [];
|
||||
list.push(e);
|
||||
byRoute.set(e.source, list);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user