Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- TanStack Start server routes now link to their HTTP handlers while keeping API-only files out of the page map, including routes introduced after initial indexing.

- React Router framework-mode pages now appear with their page components and navigation, including nested routes and pathless layouts.

- Endpoint discovery now recognizes literal routes in Hono, Elysia, Fastify, Hyper-Express, Koa router, H3, Bun, Effect v4 and option-free Vixeny builders, and correctly reads Nuxt 4 page groups and server route methods after re-indexing.
Expand Down
199 changes: 199 additions & 0 deletions __tests__/tanstack-start.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
import { tanstackRouterResolver } from '../src/resolution/frameworks/tanstack-router';
import { routeRoots } from '../src/ui-server/api/route-roots';

beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']);
});
const wrap = (
options: string,
factory = 'createFileRoute',
) => `import {createFileRoute${factory === 'createFileRoute' ? '' : ' as ' + factory}} from '@tanstack/react-router';
export const Route = ${factory}('/api/$id')(${options});`;
const extract = (source: string) =>
tanstackRouterResolver.extract!('src/routes/api.$id.tsx', source);

describe('TanStack Start server declarations', () => {
it('emits method-qualified routes without a phantom page', () => {
const result = extract(wrap('{server:{handlers:{GET:getItem,POST:saveItem,ANY:fallback}}}'));
expect(result.nodes.map((n) => n.name)).toEqual([
'GET /api/:id',
'POST /api/:id',
'ANY /api/:id',
]);
expect(result.references.map((r) => [r.referenceName, r.referenceKind])).toEqual([
['getItem', 'references'],
['saveItem', 'references'],
['fallback', 'references'],
]);
});
it('retains a mixed page/API route and imported factory alias', () => {
const result = extract(wrap('{component:Page,server:{handlers:{GET:load}}}', 'fileRoute'));
expect(result.nodes.map((n) => n.name)).toEqual(['GET /api/:id', '/api/:id']);
expect(result.references.map((r) => r.referenceName)).toEqual(['load', 'Page']);
});
it('reads the official createHandlers form and excludes middleware', () => {
// TanStack/router@a58e01c604e2d189ef8c8c1ad6ac8747e03aa88c,
// docs/start/framework/react/guide/server-routes.md:172–186.
const result = extract(
wrap(`{server:{handlers:({createHandlers})=>createHandlers({
GET:{middleware:[loggerMiddleware],handler:({request})=>respond(request)},POST:save})}}`),
);
expect(result.nodes.map((n) => n.name)).toEqual(['GET /api/:id', 'POST /api/:id']);
expect(result.references.map((r) => [r.referenceName, r.referenceKind])).toEqual([
['respond', 'calls'],
['save', 'references'],
]);
});
it('supports destructured helper aliases, block returns, and method shorthand', () => {
const result = extract(
wrap(
`{server:{handlers:({createHandlers: make})=>{return make({GET(){return respond()},POST:{handler:save}})}}}`,
),
);
expect(result.nodes.map((n) => n.name)).toEqual(['GET /api/:id', 'POST /api/:id']);
expect(result.references.map((r) => r.referenceName)).toEqual(['respond', 'save']);
});
it.each([
'{server:{handlers:dynamic}}',
'{server}',
'{server:{handlers:{GET:load,...extra}}}',
'{server:{handlers:{[verb]:load}}}',
'{server:{handlers:{GET:{...options,handler:load}}}}',
'{server:{handlers:({createHandlers})=>{const createHandlers=other;return createHandlers({GET:load})}}}',
'{server:{handlers:()=>createHandlers({GET:load})}}',
'{server:{handlers:{GET:object.handler}}}',
'{server:{handlers:{GET:load}},...options}',
])('does not fabricate an endpoint or page from unsupported options %s', (options) => {
expect(extract(wrap(options)).nodes).toEqual([]);
});
it('ignores unregistered, shadowed, type-only and unrelated factories', () => {
for (const source of [
`import {createFileRoute} from 'other';export const Route=createFileRoute('/x')({server:{handlers:{GET:load}}});`,
`import {type createFileRoute} from '@tanstack/react-router';export const Route=createFileRoute('/x')({server:{handlers:{GET:load}}});`,
`import type {createFileRoute} from '@tanstack/react-router';export const Route=createFileRoute('/x')({server:{handlers:{GET:load}}});`,
`import {createFileRoute} from '@tanstack/react-router';function fn(createFileRoute){const Route=createFileRoute('/x')({server:{handlers:{GET:load}}});}`,
`import {createFileRoute} from '@tanstack/react-router';const unused=createFileRoute('/x')({server:{handlers:{GET:load}}});`,
])
expect(extract(source).nodes).toEqual([]);
});
it.each(['undefined', 'null', 'false'])(
'does not turn component: %s into a page',
(component) => {
expect(
extract(wrap(`{component:${component},server:{handlers:{GET:load}}}`)).nodes.map(
(n) => n.name,
),
).toEqual(['GET /api/:id']);
},
);
it('does not turn RPC functions into public endpoints or callback locals into call targets', () => {
expect(
extract(
`import {createServerFn} from '@tanstack/react-start';export const rpc=createServerFn({method:'POST'}).handler(load);`,
).nodes,
).toEqual([]);
expect(
extract(
wrap('{server:{handlers:{GET:(load)=>load(),POST:()=>{const save=other;return save()}}}}'),
).references,
).toEqual([]);
});
it('normalizes pathless/group routes but still finds their server methods', () => {
expect(
extract(
wrap('{server:{handlers:{GET:load}}}').replace('/api/$id', '/(_group)/_auth/items_/$id'),
).nodes.map((n) => n.name),
).toEqual(['GET /items/:id']);
});
});

describe('Start routes through indexing and sync', () => {
let cg: CodeGraph | undefined;
let dir: string;
afterEach(() => {
cg?.close();
cg = undefined;
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
const write = (file: string, source: string) => {
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
fs.writeFileSync(path.join(dir, file), source);
};
it('binds imported handlers and inline calls, preserves page roots, and tracks edits/deletion', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-start-'));
write(
'package.json',
JSON.stringify({
dependencies: { '@tanstack/react-start': '*', '@tanstack/react-router': '*' },
}),
);
write(
'src/handlers.ts',
'export function load(){return 1;} export function respond(){return 2;}',
);
write(
'src/routes/api.$id.tsx',
`import {load,respond} from '../handlers';
${wrap('{component:Page,server:{middleware:[ignored],handlers:({createHandlers})=>createHandlers({GET:load,POST:{handler:()=>respond()}})}}')}
function Page(){return <div/>;}`,
);
write(
'src/nav.tsx',
`import {redirect} from '@tanstack/react-router';export function go(){return redirect({to:'/api/$id'});}`,
);
cg = await CodeGraph.init(dir, { index: true });
const routes = cg.getNodesByKind('route');
expect(routes.map((n) => n.name).sort()).toEqual(['/api/:id', 'GET /api/:id', 'POST /api/:id']);
const roots = routeRoots(cg, routes);
expect(roots.get(routes.find((n) => n.name === 'GET /api/:id')!.id)?.node.name).toBe('load');
expect(roots.get(routes.find((n) => n.name === '/api/:id')!.id)?.node.name).toBe('Page');
const go = cg.getNodesByKind('function').find((n) => n.name === 'go')!;
expect(cg.getOutgoingEdges(go.id)).toContainEqual(
expect.objectContaining({
kind: 'navigates',
target: routes.find((n) => n.name === '/api/:id')!.id,
}),
);
const post = routes.find((n) => n.name === 'POST /api/:id')!;
const respond = cg.getNodesByKind('function').find((n) => n.name === 'respond')!;
expect(cg.getOutgoingEdges(post.id)).toContainEqual(
expect.objectContaining({ kind: 'calls', target: respond.id }),
);
write(
'src/routes/api.$id.tsx',
`import {load} from '../handlers';\n${wrap('{server:{handlers:{DELETE:load}}}')}`,
);
await cg.sync();
expect(cg.getNodesByKind('route').map((n) => n.name)).toEqual(['DELETE /api/:id']);
fs.unlinkSync(path.join(dir, 'src/routes/api.$id.tsx'));
await cg.sync();
expect(cg.getNodesByKind('route')).toEqual([]);
});
it.each([false, true])('discovers Start after initial indexing (scoped=%s)', async (scoped) => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-start-new-'));
write('package.json', '{}');
write('src/handler.ts', 'export function load(){return 1;}');
cg = await CodeGraph.init(dir, { index: true });
write(
'package.json',
JSON.stringify({
dependencies: { '@tanstack/react-start': '*', '@tanstack/react-router': '*' },
}),
);
write(
'src/routes/api.$id.ts',
`import {load} from '../handler';\n${wrap('{server:{handlers:{GET:load}}}')}`,
);
await cg.sync(scoped ? { paths: ['src/routes/api.$id.ts'] } : {});
const routes = cg.getNodesByKind('route');
expect(routes.map((n) => n.name)).toEqual(['GET /api/:id']);
expect(routeRoots(cg, routes).get(routes[0].id)?.node.name).toBe('load');
});
});
4 changes: 2 additions & 2 deletions docs/design/PLAN-application-router-coverage.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Status: 1/13 — all steps approved; step 1 verified, preparing its PR
Status: 2/13 — step 2 verified, preparing its PR; step 1 published as PR #3

- [x] 1 React Router framework mode — seven official-fixture pages bind exact components; nested index/navigation, config/module sync and fresh compiled workers verified; build passes, 112 WASM focused/control tests pass, full native suite 4,353 pass / 46 skip; independent review clear.
- [ ] 2 TanStack Start server routes — method-qualified endpoints from literal `server.handlers` tables and documented `createHandlers` callback forms. Keep page and endpoint nodes when both exist; omit phantom pages for server-only files. Gate: shared proof, mixed page/API fixtures, middleware exclusion, and handler call edges.
- [x] 2 TanStack Start server routes — literal method tables and `createHandlers` bind handlers/calls; page/API coexistence and full/scoped sync verified; build passes, 62 WASM focused/control tests pass, full native suite 4,375 pass / 46 skip; independent review clear.
- [ ] 3 Remix and React Router file conventions — support the pinned default file convention, including index, nested, parameter, pathless, and splat cases; require evidence that the convention is enabled. Gate: shared proof, explicit-config/file-route coexistence, and layout-only controls.
- [ ] 4 Astro route completion — connect existing page routes to components and API method exports, then navigation where the source declares it. Gate: shared proof, page/API distinction, underscore exclusions, and cross-file handler resolution.
- [ ] 5 RedwoodSDK — imported `rwsdk/router` declarations, method tables, and statically resolvable registration/prefix context. Gate: shared proof, `defineApp`/`render` composition, and interrupters excluded from page roots.
Expand Down
4 changes: 3 additions & 1 deletion docs/design/framework-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,14 @@ guessed.
| 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 |
| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts`, `react-router-framework.test.ts` | proshop; pinned official framework config (7 pages) |
| TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend |
| TanStack Router / Start | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts`, `tanstack-start.test.ts` | TanStack examples, fastapi-template frontend; pinned Start server-handler syntax |
| Vue Router / Nuxt | `frameworks/vue-router.ts` | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges) |
| SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts` | sveltekit-realworld (31 edges) |

React Router framework mode reads default exported literal arrays in `app/routes.ts` or `app/routes.js`, using imported `route`, `index`, `layout`, and spread `prefix` helpers. Module paths bind named default components; nested index pages take precedence over their parent. [Official source fixture](https://github.com/remix-run/react-router/blob/7aea711dd1ae2bc5a076d13ff17291829690fa74/docs/start/framework/routing.md#L28): seven expected pages, verified through indexing and navigation. Tests also cover module/config sync and a fresh compiled process using parse/resolver workers. Custom app directories, computed arrays, `relative`, anonymous defaults, and re-exports remain unsupported.

TanStack Start reads imported `createFileRoute` calls assigned to exported `const Route`: literal `server.handlers` tables, including `ANY`, and the destructured `createHandlers` callback form. Named handlers and direct inline calls bind through the existing HTTP reader. Page/API combinations retain both nodes; server-only routes do not become pages. [Pinned official handler syntax](https://github.com/TanStack/router/blob/a58e01c604e2d189ef8c8c1ad6ac8747e03aa88c/docs/start/framework/react/guide/server-routes.md#L172), [executable middleware fixture](https://github.com/TanStack/router/blob/a58e01c604e2d189ef8c8c1ad6ac8747e03aa88c/e2e/react-start/server-routes/src/routes/api/middleware-context.ts). Computed/spread tables, member handlers, custom factories and server `update` chains remain unresolved. `createServerFn` has no declared public route and gets no fabricated endpoint.

Shared machinery all six use, in `frameworks/expo-router.ts`: `RouteTable` /
`RootedRouteTable`, `routesForFile`, `addRouteTo`, `matchRoute`, `appRootFor`,
`parseHrefExpression`, `readHrefViaLocal`, `nthArgumentText`, `readStringAt`,
Expand Down
3 changes: 3 additions & 0 deletions site/src/content/docs/guides/framework-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
| **Vapor** | `app.get("x", use: handler)` |
| **React Router** | JSX/data-router pages; literal framework-mode `app/routes.ts` arrays with `route`, `index`, `layout`, and `prefix`, linked to named default components |
| **SvelteKit** | Route component nodes |
| **TanStack Router / Start** | Page routes plus literal `server.handlers` method tables and `createHandlers` callbacks on exported file routes; middleware is excluded from handler links |
| **Next.js** | App Router and Pages Router pages; `app/api/**/route.ts` method exports and `pages/api/**` default handlers |
| **Vue Router** / **Nuxt** | Vue route tables; `.vue` pages in `pages/` or Nuxt 4 `app/pages/`, dynamic/optional/catch-all segments and route groups; `server/api/` and `server/routes/` with method suffixes; route middleware |
| **Astro** | `src/pages/` file-based routes (`.astro` pages + `.ts` endpoints, `[param]`/`[...rest]` syntax) |
Expand All @@ -42,4 +43,6 @@ React Router framework mode assumes the default `app/` directory. Computed array

The JavaScript HTTP readers require a recognized package import (ES modules or CommonJS), except for the global `Bun.serve`. They follow immutable local router bindings and literal declarations, without executing your application. Named handlers produce references; direct calls inside inline handlers produce call edges. Static responses have an endpoint without an invented handler. Member handlers remain unresolved by this reader.

TanStack Start server routes support imported `createFileRoute` calls assigned to exported `const Route`. Computed/spread tables, custom factories, member handlers, and `update` chains remain unsupported. RPC functions created with `createServerFn` are not presented as public route URLs.

Computed paths, spread configuration, cross-file mounts, plugin factories, mutable router aliases, and runtime method replacement are outside this static reading. Imports and captured router bindings must precede their use in source. Vixeny builders with options are omitted because their effective paths depend on the terminal operation. Nuxt custom route configuration, page metadata overrides, non-Vue page extensions, and custom server handler wrappers are not interpreted. Re-index after upgrading to add the new endpoints to an existing graph.
5 changes: 5 additions & 0 deletions src/extraction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2873,6 +2873,11 @@ export class ExtractionOrchestrator {

// Load only grammars needed for changed files
if (filesToIndex.length > 0) {
const previous = this.detectedFrameworkNames ?? [];
this.detectedFrameworkNames = null;
const detected = this.ensureDetectedFrameworks(currentFiles);
// A watcher scope sees only changed files; retain other packages' frameworks.
if (scopedPaths?.length) this.detectedFrameworkNames = [...new Set([...previous, ...detected])];
const overrides = loadExtensionOverrides(this.rootDir);
const neededLanguages = [...new Set(filesToIndex.map((f) => detectLanguage(f, undefined, overrides)))];
// .h files default to 'c' but may be C++ — ensure cpp grammar is loaded
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,7 @@ export class CodeGraph {
// to controllers in unchanged files. The pass is idempotent and cheap
// (regex over *.module.ts only).
if (result.filesAdded > 0 || result.filesModified > 0) {
this.resolver.initialize();
if (this.queries.getNodesByKind('route').some(n => n.id.startsWith('route:react-router:')))
await loadGrammarsForLanguages(['typescript', 'javascript', 'tsx', 'jsx']);
this.resolver.runPostExtract();
Expand Down
Loading